diff --git a/babel.test.config.mjs b/babel.test.config.mjs index f83bb403a1f..b2705501e39 100644 --- a/babel.test.config.mjs +++ b/babel.test.config.mjs @@ -9,12 +9,29 @@ import { createRequire } from 'node:module'; import baseConfig from './babel.config.mjs'; +import { buildMacros } from '@embroider/macros/babel'; // eslint-disable-next-line no-redeclare const require = createRequire(import.meta.url); const buildDebugMacroPlugin = require('./broccoli/build-debug-macro-plugin.cjs'); +const appEmberSatisfiesPlugin = require('./broccoli/app-ember-satisfies-plugin.cjs'); const isProduction = process.env.EMBER_ENV === 'production'; +// @ember/test-helpers, @ember/test-waiters and ember-qunit use @embroider/macros. +// +// buildMacros() reads NODE_ENV, but our builds select the mode with EMBER_ENV. +const macros = buildMacros({ + setConfig: { + // index.html loads the QUnit styles, and tests render into #qunit-fixture. + 'ember-qunit': { theme: 'none', disableContainerStyles: true }, + }, + configure(config) { + if (!isProduction) { + config.enablePackageDevelopment(process.cwd()); + } + }, +}); + export default { ...baseConfig, @@ -27,5 +44,10 @@ export default { ], ], - plugins: [...baseConfig.plugins, ...buildDebugMacroPlugin(!isProduction)], + plugins: [ + ...baseConfig.plugins, + appEmberSatisfiesPlugin, + ...macros.babelMacros, + ...buildDebugMacroPlugin(!isProduction), + ], }; diff --git a/bin/benchmark/run.mjs b/bin/benchmark/run.mjs index 3a1cf6bcc0a..454376b7386 100644 --- a/bin/benchmark/run.mjs +++ b/bin/benchmark/run.mjs @@ -177,6 +177,10 @@ async function bootAndRun({ headless = true } = {}) { // as the contradictory flags cause use-after-free crashes on macOS '--disable-gpu', '--disable-gpu-compositing', + // Exposes gc(), which the benchmark app calls between phases (see + // waitForIdle in smoke-tests/benchmark-app/app/run-benchmark.js), so + // the garbage of one phase is not collected inside a later one. + '--js-flags=--expose-gc', ].join(','), ]; diff --git a/broccoli/app-ember-satisfies-plugin.cjs b/broccoli/app-ember-satisfies-plugin.cjs new file mode 100644 index 00000000000..c1217c19000 --- /dev/null +++ b/broccoli/app-ember-satisfies-plugin.cjs @@ -0,0 +1,32 @@ +const semver = require('semver'); +const { version } = require('../package.json'); + +/* + `appEmberSatisfies` from @embroider/macros reads the ember-source version + from the dependencies of the app. + + In this repo the app is ember-source, and a package cannot depend on itself. + So this plugin answers the macro with the version of this repo, before + @embroider/macros sees it. +*/ +module.exports = function appEmberSatisfiesPlugin({ types: t }) { + return { + name: 'app-ember-satisfies', + visitor: { + Program(program) { + program.traverse({ + CallExpression(call) { + if (!call.get('callee').referencesImport('@embroider/macros', 'appEmberSatisfies')) { + return; + } + + let [range] = call.node.arguments; + let satisfied = semver.satisfies(version, range.value, { includePrerelease: true }); + + call.replaceWith(t.booleanLiteral(satisfied)); + }, + }); + }, + }, + }; +}; diff --git a/index.html b/index.html index ba2b0bcf2fe..d67e07a7834 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,14 @@
diff --git a/package.json b/package.json index dfac084f3da..68a07542477 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@babel/plugin-transform-typescript": "^7.26.8", "@babel/preset-env": "^7.16.11", "@babel/types": "^7.22.5", + "@ember/test-helpers": "^5.7.0", "@embroider/macros": "^1.20.2", "@embroider/shared-internals": "^3.0.2", "@embroider/vite": "^1.7.2", @@ -131,6 +132,7 @@ "ember-cli-browserstack": "^4.0.0", "ember-cli-dependency-checker": "^3.3.1", "ember-cli-yuidoc": "^0.9.1", + "ember-qunit": "^9.1.0", "eslint": "^9.21.0", "eslint-import-resolver-node": "^0.3.10", "eslint-plugin-disable-features": "^0.1.3", @@ -150,6 +152,7 @@ "npm-run-all2": "^8.0.0", "prettier": "^3.5.3", "qunit": "^2.19.4", + "qunit-dom": "^3.6.0", "recast": "^0.22.0", "release-plan": "^0.19.0", "resolve.exports": "^2.0.3", diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 90addfe68e5..5776f3831ed 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -33,13 +33,41 @@ import ResolverImpl from './resolver'; import { renderers } from './renderers'; import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context'; +/** + * The parts of this module, in the order that they appear: + * + * renderComponent() + * │ + * │ finds or creates, one per owner + * ▼ + * BaseRenderer ───► RendererState ───► RendererRoot[] ───► GlimmerRenderResult + * + * the public the roots and one per the rendered + * surface the render loop rendered tree DOM + * + * Each arrow is also a destroyable parent → child link. + * The destruction of a part destroys everything to its right. + * + * The run loop hooks keep every renderer in the `renderers` list current. + * They come after `BaseRenderer`. + */ + export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder; +type IntoTarget = Cursor | Element | SimpleElement; + const NO_OP = () => {}; -// This wrapper logic prevents us from rerendering in case of a hard failure -// during render. This prevents infinite revalidation type loops from occuring, -// and ensures that errors are not swallowed by subsequent follow on failures. +/** + * Wraps a render function so that it runs until its first error. + * After an error, each call only warns. + * + * A failed render leaves the tree in an unknown state. + * A second render on that tree throws again and hides the first error, + * and it can repeat without end. + * + * Production builds return `fn` as it is. + */ export function errorLoopTransaction(fn: () => void) { if (DEBUG) { return () => { @@ -50,8 +78,6 @@ export function errorLoopTransaction(fn: () => void) { didError = false; } finally { if (didError) { - // Noop the function so that we won't keep calling it and causing - // infinite looping failures; fn = () => { // eslint-disable-next-line no-console console.warn( @@ -67,9 +93,16 @@ export function errorLoopTransaction(fn: () => void) { } /** - * The interface the `RendererState` needs from a render root. The base - * renderer only ever creates `ComponentRootState`s; the classic renderer - * (`./renderer`) adds `ClassicRootState` for outlet/classic-component roots. + * ---------------------------------------------------------------------------- + * Roots + * ---------------------------------------------------------------------------- + */ + +/** + * What `RendererState` needs from a root. + * + * - `ComponentRootState` is the only root that this module creates. + * - `ClassicRootState` in `./renderer` covers outlets and classic components. */ export interface RendererRoot { readonly type: string; @@ -91,26 +124,26 @@ export class ComponentRootState implements RendererRoot { definition: object, options: { into: Cursor; args?: Record } ) { + /** + * One guard covers the first render and every rerender. + * An error in either one stops all later renders of this root. + */ this.#render = errorLoopTransaction(() => { - let iterator = glimmerRenderComponent( - state.context, - state.builder(state.env, options.into), - state.owner, - definition, - options?.args - ); - - let result = (this.#result = iterator.sync()); - - associateDestroyableChild(this, this.#result); - - this.#render = errorLoopTransaction(() => { - if (isDestroying(result) || isDestroyed(result)) return; - - return result.rerender({ - alwaysRevalidate: false, - }); - }); + let result = this.#result; + + if (result === undefined) { + let iterator = glimmerRenderComponent( + state.context, + state.builder(state.env, options.into), + state.owner, + definition, + options.args + ); + + this.#result = associateDestroyableChild(this, iterator.sync()); + } else if (!isDestroying(result)) { + result.rerender({ alwaysRevalidate: false }); + } }); } @@ -119,6 +152,13 @@ export class ComponentRootState implements RendererRoot { } render(): void { + /** + * A root can be destroyed before its first render. + * For example, a render transaction adds the root, + * and the root is destroyed before the transaction gets to it. + */ + if (isDestroying(this)) return; + this.#render(); } @@ -135,116 +175,54 @@ export class ComponentRootState implements RendererRoot { } } -export function _resetRenderers() { - renderers.length = 0; -} - -function register(renderer: BaseRenderer): void { - assert('Cannot register the same renderer twice', renderers.indexOf(renderer) === -1); - renderers.push(renderer); -} - -function deregister(renderer: BaseRenderer): void { - let index = renderers.indexOf(renderer); - assert('Cannot deregister unknown unregistered renderer', index !== -1); - renderers.splice(index, 1); -} - -function loopBegin(): void { - for (let renderer of renderers) { - renderer.rerender(); - } -} - -interface RenderSettledDeferred { - promise: Promise; - resolve: () => void; -} - -let renderSettledDeferred: RenderSettledDeferred | null = null; -/* - Returns a promise which will resolve when rendering has settled. Settled in - this context is defined as when all of the tags in use are "current" (e.g. - `renderers.every(r => r._isValid())`). When this is checked at the _end_ of - the run loop, this essentially guarantees that all rendering is completed. - - @method renderSettled - @returns {Promise} a promise which fulfills when rendering has settled -*/ -export function renderSettled() { - if (renderSettledDeferred === null) { - let resolve!: () => void; - let promise = new Promise((r) => (resolve = r)); - renderSettledDeferred = { promise, resolve }; - // if there is no current runloop, the promise created above will not have - // a chance to resolve (because its resolved in backburner's "end" event) - if (!_getCurrentRunLoop()) { - // ensure a runloop has been kicked off - _backburner.schedule('actions', null, NO_OP); - } - } - - return renderSettledDeferred.promise; -} - -function resolveRenderPromise() { - if (renderSettledDeferred !== null) { - let resolve = renderSettledDeferred.resolve; - renderSettledDeferred = null; - - _backburner.join(null, resolve); - } -} - -let loops = 0; -function loopEnd() { - for (let renderer of renderers) { - if (!renderer.isValid()) { - if (loops > ENV._RERENDER_LOOP_LIMIT) { - loops = 0; - // TODO: do something better - renderer.destroy(); - throw new Error('infinite rendering invalidation detected'); - } - loops++; - return _backburner.join(null, NO_OP); - } - } - loops = 0; - resolveRenderPromise(); -} +/** + * ---------------------------------------------------------------------------- + * RendererState + * ---------------------------------------------------------------------------- + */ -_backburner.on('begin', loopBegin); -_backburner.on('end', loopEnd); +/** + * The roots of one renderer, and the loop that renders them. + */ +export class RendererState { + readonly owner: object; + readonly context: EvaluationContext; + readonly builder: IBuilder; -type Resolver = ClassicResolver; + readonly #renderer: BaseRenderer; -interface RendererData { - owner: object; - context: EvaluationContext; - builder: IBuilder; -} + #roots: RendererRoot[] = []; -export class RendererState { - static create(data: RendererData, renderer: BaseRenderer): RendererState { - const state = new RendererState(data, renderer); - associateDestroyableChild(renderer, state); - return state; - } + /** + * Roots that were destroyed while `renderRoots` iterated `#roots`. + * `renderRoots` removes them when it finishes. + */ + #removedRoots: RendererRoot[] = []; - readonly #data: RendererData; + /** + * The value of the global revision counter at the end of the last render. + * See `isValid`. + */ #lastRevision = -1; + #inRenderTransaction = false; - #destroyed = false; - #roots: RendererRoot[] = []; - #removedRoots: RendererRoot[] = []; - private constructor(data: RendererData, renderer: BaseRenderer) { - this.#data = data; + /** + * True while the renderer is in the global `renderers` list. + */ + #registered = false; - registerDestructor(this, () => { - this.clearAllRoots(renderer); - }); + constructor( + renderer: BaseRenderer, + data: { owner: object; context: EvaluationContext; builder: IBuilder } + ) { + this.#renderer = renderer; + this.owner = data.owner; + this.context = data.context; + this.builder = data.builder; + + associateDestroyableChild(renderer, this); + registerDestructor(this, () => this.clearAllRoots()); } get debug() { @@ -259,57 +237,63 @@ export class RendererState { return this.#roots; } - get owner(): object { - return this.#data.owner; - } - - get builder(): IBuilder { - return this.#data.builder; - } - - get context(): EvaluationContext { - return this.#data.context; - } - get env(): Environment { return this.context.env; } get isInteractive(): boolean { - return this.#data.context.env.isInteractive; + return this.context.env.isInteractive; } - renderRoot(root: RendererRoot, renderer: BaseRenderer): RendererRoot { - let roots = this.#roots; - - roots.push(root); + /** + * Adds a root, and renders it. + * + * If a render is in progress, this returns before the root renders. + * `root.result` is then `undefined` until the next pass of `renderRoots`. + */ + renderRoot(root: RendererRoot): RendererRoot { + this.#roots.push(root); associateDestroyableChild(this, root); - if (roots.length === 1) { - register(renderer); - } + /** + * A destroyed root must leave `#roots`. + * + * If it stays, the root never becomes garbage. + * The renderer does not become garbage either, + * because a renderer with roots stays in the global `renderers` list. + */ + registerDestructor(root, () => { + if (this.#inRenderTransaction) { + if (!this.#removedRoots.includes(root)) this.#removedRoots.push(root); + } else { + this.#removeRoot(root); + } + }); - this.#renderRootsTransaction(renderer); + this.#register(); + this.#renderRootsTransaction(); return root; } - #renderRootsTransaction(renderer: BaseRenderer): void { - if (this.#inRenderTransaction) { - // currently rendering roots, a new root was added and will - // be processed by the existing _renderRoots invocation - return; - } + #renderRootsTransaction(): void { + /** + * The `renderRoots` call that is in progress picks up new roots. + */ + if (this.#inRenderTransaction) return; - // used to prevent calling _renderRoots again (see above) - // while we are actively rendering roots this.#inRenderTransaction = true; let completedWithoutError = false; try { - this.renderRoots(renderer); + this.renderRoots(); completedWithoutError = true; } finally { + /** + * After an error, mark the state as current. + * If it stays invalid, `loopEnd` starts one more run loop, + * and that run loop throws the same error again. + */ if (!completedWithoutError) { this.#lastRevision = valueForTag(CURRENT_TAG); } @@ -317,7 +301,19 @@ export class RendererState { } } - renderRoots(renderer: BaseRenderer): void { + /** + * Renders every root, and repeats while a render adds more roots. + * + * A component can call `renderComponent` while it renders. + * The new root waits for the next pass: + * + * pass 1 roots = [a, b] render a, render b b adds c + * pass 2 roots = [a, b, c] rerender a, b render c + * stop pass 2 added no root + * + * A rerender of a root with no changes does no DOM work. + */ + renderRoots(): void { let roots = this.#roots; let removedRoots = this.#removedRoots; let initialRootsLength: number; @@ -326,26 +322,22 @@ export class RendererState { initialRootsLength = roots.length; inTransaction(this.context.env, () => { - // ensure that for the first iteration of the loop - // each root is processed + /** + * `roots.length` is read on each iteration, because a render can + * + * - add roots, which this pass skips + * - remove roots, which makes the array shorter + */ for (let i = 0; i < roots.length; i++) { let root = roots[i]; assert('has root', root); if (root.destroyed) { - // add to the list of roots to be removed - // they will be removed from `this._roots` later removedRoots.push(root); - - // skip over roots that have been marked as destroyed continue; } - // when processing non-initial reflush loops, - // do not process more roots than needed - if (i >= initialRootsLength) { - continue; - } + if (i >= initialRootsLength) continue; root.render(); } @@ -354,37 +346,62 @@ export class RendererState { }); } while (roots.length > initialRootsLength); - // remove any roots that were destroyed during this transaction while (removedRoots.length) { - let root = removedRoots.pop(); + this.#removeRoot(removedRoots.pop()!); + } + } - let rootIndex = roots.indexOf(root!); + #removeRoot(root: RendererRoot): void { + let roots = this.#roots; + let rootIndex = roots.indexOf(root); + + if (rootIndex !== -1) { roots.splice(rootIndex, 1); } - if (this.#roots.length === 0) { - deregister(renderer); + if (roots.length === 0) { + this.#deregister(); + } + } + + #register(): void { + if (!this.#registered) { + this.#registered = true; + register(this.#renderer); + } + } + + #deregister(): void { + if (this.#registered) { + this.#registered = false; + deregister(this.#renderer); } } - scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + scheduleRevalidate(): void { + _backburner.scheduleOnce('render', this, this.revalidate); } + /** + * True when there is nothing to render: + * no roots, or no tracked value changed after the last render. + * + * `CURRENT_TAG` stands for every tag, + * so one changed value anywhere in the app makes every renderer invalid. + * Each root then finds out for itself if the change applies to it. + */ isValid(): boolean { - return ( - this.#destroyed || this.#roots.length === 0 || validateTag(CURRENT_TAG, this.#lastRevision) - ); + return this.#roots.length === 0 || validateTag(CURRENT_TAG, this.#lastRevision); } - revalidate(renderer: BaseRenderer): void { + revalidate(): void { if (this.isValid()) { return; } - this.#renderRootsTransaction(renderer); + this.#renderRootsTransaction(); } - clearAllRoots(renderer: BaseRenderer): void { + clearAllRoots(): void { let roots = this.#roots; for (let root of roots) { destroy(root); @@ -393,15 +410,215 @@ export class RendererState { this.#removedRoots.length = 0; this.#roots = []; - // if roots were present before destroying - // deregister this renderer instance - if (roots.length) { - deregister(renderer); + this.#deregister(); + } +} + +/** + * ---------------------------------------------------------------------------- + * BaseRenderer + * ---------------------------------------------------------------------------- + */ + +export class BaseRenderer { + /** + * Creates a renderer that has no router. + * + * Its resolver does not know `{{outlet}}` and `{{mount}}`. + * The `Renderer` in `./renderer` adds those. + */ + static strict( + owner: object, + document: SimpleDocument | Document, + options: { isInteractive: boolean; hasDOM?: boolean } + ) { + return new BaseRenderer( + owner, + { hasDOM: hasDOM, ...options }, + document as SimpleDocument, + new ResolverImpl(), + clientBuilder + ); + } + + readonly state: RendererState; + + constructor( + owner: object, + envOptions: { isInteractive: boolean; hasDOM: boolean }, + document: SimpleDocument, + resolver: ClassicResolver, + builder: IBuilder + ) { + let sharedArtifacts = artifacts(); + + /** + * SAFETY: are there consequences for being looser with *this* owner? + * the public API for `owner` is kinda `Partial` + * aka: implement only what you need. + * But for actual ember apps, you *need* to implement everything + * an app needs (which will actually change and become less over time) + */ + let env = new EmberEnvironmentDelegate(owner as InternalOwner, envOptions.isInteractive); + let options = runtimeOptions({ document }, env, sharedArtifacts, resolver); + let context = new EvaluationContextImpl( + sharedArtifacts, + (heap) => new RuntimeOpImpl(heap), + options + ); + + this.state = new RendererState(this, { owner, context, builder }); + } + + get debugRenderTree(): DebugRenderTree { + let { debugRenderTree } = this.state.env; + + assert( + 'Attempted to access the DebugRenderTree, but it did not exist. Is the Ember Inspector open?', + debugRenderTree + ); + + return debugRenderTree; + } + + isValid(): boolean { + return this.state.isValid(); + } + + destroy() { + destroy(this); + } + + render( + component: object, + options: { into: IntoTarget; args?: Record } + ): RendererRoot { + const root = new ComponentRootState(this.state, component, { + args: options.args, + into: toCursor(options.into), + }); + return this.state.renderRoot(root); + } + + rerender(): void { + this.state.scheduleRevalidate(); + } +} + +/** + * ---------------------------------------------------------------------------- + * Run loop hooks + * ---------------------------------------------------------------------------- + * + * Backburner emits `begin` and `end` for each run loop. + * These two events keep every registered renderer current: + * + * begin ───► loopBegin each renderer schedules `revalidate` + * │ in the `render` queue + * ▼ + * queues flush `revalidate` renders the roots, + * │ if a tracked value changed + * ▼ + * end ─────► loopEnd is every renderer valid? + * + * yes → resolve `renderSettled()` + * no → start one more run loop, back to `begin` + * + * The answer is "no" when code that ran after the `render` queue + * changed a tracked value. + */ + +export function _resetRenderers() { + renderers.length = 0; +} + +function register(renderer: BaseRenderer): void { + assert('Cannot register the same renderer twice', renderers.indexOf(renderer) === -1); + renderers.push(renderer); +} + +function deregister(renderer: BaseRenderer): void { + let index = renderers.indexOf(renderer); + assert('Cannot deregister unknown unregistered renderer', index !== -1); + renderers.splice(index, 1); +} + +function loopBegin(): void { + for (let renderer of renderers) { + renderer.rerender(); + } +} + +/** + * The number of run loops in a row that `loopEnd` started. + */ +let loops = 0; + +function loopEnd() { + for (let renderer of renderers) { + if (!renderer.isValid()) { + if (loops > ENV._RERENDER_LOOP_LIMIT) { + loops = 0; + // TODO: do something better + renderer.destroy(); + throw new Error('infinite rendering invalidation detected'); + } + loops++; + return _backburner.join(null, NO_OP); } } + loops = 0; + resolveRenderPromise(); } -type IntoTarget = Cursor | Element | SimpleElement; +_backburner.on('begin', loopBegin); +_backburner.on('end', loopEnd); + +interface RenderSettledDeferred { + promise: Promise; + resolve: () => void; +} + +let renderSettledDeferred: RenderSettledDeferred | null = null; + +/** + * Returns a promise that resolves when rendering settles. + * + * Rendering is settled when every renderer is valid at the end of a run loop. + * All callers that wait at the same time get the same promise. + */ +export function renderSettled() { + if (renderSettledDeferred === null) { + let resolve!: () => void; + let promise = new Promise((r) => (resolve = r)); + renderSettledDeferred = { promise, resolve }; + + /** + * Only the `end` event of a run loop resolves the promise. + * With no run loop open, start one. + */ + if (!_getCurrentRunLoop()) { + _backburner.schedule('actions', null, NO_OP); + } + } + + return renderSettledDeferred.promise; +} + +function resolveRenderPromise() { + if (renderSettledDeferred !== null) { + let resolve = renderSettledDeferred.resolve; + renderSettledDeferred = null; + + _backburner.join(null, resolve); + } +} + +/** + * ---------------------------------------------------------------------------- + * renderComponent + * ---------------------------------------------------------------------------- + */ /** * The returned object from `renderComponent` @@ -415,31 +632,122 @@ export interface RenderResult { destroy(): void; } -interface RenderCacheEntry { +interface RenderComponentEnv { + /** + * When false, modifiers will not run. + */ + isInteractive?: boolean; + /** + * All other options are forwarded to the underlying renderer. + * (its API is currently private and out of scope for this RFC, + * so passing additional things here is also considered private API) + */ + [rendererOption: string]: unknown; +} + +interface LastRender { result: RenderResult; /** - * The GlimmerRenderResult from the last render. Used to get positional - * information (firstNode) when a re-render replaces the content, so - * that the new content is placed at the same DOM position. + * `undefined` when the render waits for a render transaction to get to it. */ glimmerResult: GlimmerRenderResult | undefined; } -// Cursor descriptor isn't a stable enough reference. -// Cursor fails during application teardown -type RendererCacheKey = Element | SimpleElement; +/** + * One renderer per owner. + * + * Two renderers for one owner have two `EvaluationContext`s, + * and their tracking frames can conflict. + */ +const RENDERER_FOR_OWNER = new WeakMap(); + +/** + * The last `renderComponent` call for each element. + * The next call for the same element replaces that content. + * + * The key is the element, also when the caller passes a `Cursor`. + * A `Cursor` is a new object on each call, and it fails during application teardown. + */ +const LAST_RENDER_INTO = new WeakMap(); + +/** + * The application seeds its `renderer:-dom` service which allows for + * router-aware resolver to resolve {{mount}} + */ +export function setRenderer(owner: object, renderer: BaseRenderer): void { + RENDERER_FOR_OWNER.set(owner, renderer); +} + +/** + * `env` applies only to the first render for an owner. + * Later renders use the renderer that the first render created. + */ +function rendererFor(owner: object, env: RenderComponentEnv | undefined): BaseRenderer { + let renderer = RENDERER_FOR_OWNER.get(owner); + + if (!renderer) { + /** + * SAFETY: we should figure out what we need out of a `document` and narrow the API. + * this exercise should also end up beginning to define what we need for CLI rendering (or to other outputs) + */ + let document = + env && 'document' in env + ? (env['document'] as SimpleDocument | Document) + : globalThis.document; + + renderer = BaseRenderer.strict(owner, document, { + ...env, + isInteractive: env?.isInteractive ?? true, + hasDOM: env && 'hasDOM' in env ? Boolean(env['hasDOM']) : true, + }); + RENDERER_FOR_OWNER.set(owner, renderer); + } + + return renderer; +} + +const isCursor = (into: IntoTarget): into is Cursor => 'element' in into; const isDOMElement = (into: IntoTarget): into is Element => 'innerHTML' in into; -const cacheKey = (into: IntoTarget): RendererCacheKey => - 'element' in into ? (into as Cursor).element : (into as RendererCacheKey); +function toCursor(into: IntoTarget): Cursor { + return isCursor(into) ? into : { element: into as SimpleElement, nextSibling: null }; +} -function intoTarget(into: IntoTarget): Cursor { - if ('element' in into) { - return into; - } else { - return { element: into as SimpleElement, nextSibling: null }; +/** + * Makes room for a render into `element`, and returns where to render. + * + * A second `renderComponent` call for one element replaces the first + * ([RFC 1099](https://github.com/emberjs/rfcs/blob/main/text/1099-renderComponent.md)). + * `destroy()` only schedules the removal of the old nodes, + * so they are still in the DOM when the new content renders. + * The new content goes before the first old node, and keeps that position: + * + * start x a1 a2 y `a` is the last render + * destroy a (scheduled) x a1 a2 y + * render b before a1 x b1 b2 a1 a2 y + * removal of a runs x b1 b2 y + * + * `x` and `y` are nodes that `renderComponent` did not make. + * + * For the first render into a DOM element, there is nothing to destroy. + * That render clears the element. + * A `Cursor` target is never cleared. + */ +function replaceLastRender(into: IntoTarget, element: Element | SimpleElement): IntoTarget { + let last = LAST_RENDER_INTO.get(element); + + if (last?.glimmerResult) { + last.result.destroy(); + + return { element: element as SimpleElement, nextSibling: last.glimmerResult.firstNode() }; + } + + if (!last && isDOMElement(into)) { + into.innerHTML = ''; } + + return into; } /** @@ -483,18 +791,7 @@ export function renderComponent( /** * Optionally configure the rendering environment */ - env?: { - /** - * When false, modifiers will not run. - */ - isInteractive?: boolean; - /** - * All other options are forwarded to the underlying renderer. - * (its API is currently private and out of scope for this RFC, - * so passing additional things here is also considered private API) - */ - [rendererOption: string]: unknown; - }; + env?: RenderComponentEnv; /** * These args get passed to the rendered component @@ -505,183 +802,38 @@ export function renderComponent( args?: Record; } ): RenderResult { - /** - * SAFETY: we should figure out what we need out of a `document` and narrow the API. - * this exercise should also end up beginning to define what we need for CLI rendering (or to other outputs) - */ - let document = - env && 'document' in env - ? (env?.['document'] as SimpleDocument | Document) - : globalThis.document; - - // Reuse renderer per owner to avoid creating multiple EvaluationContexts - // which can cause tracking frame conflicts - let renderer = RENDERER_CACHE.get(owner); - if (!renderer) { - renderer = BaseRenderer.strict(owner, document, { - ...env, - isInteractive: env?.isInteractive ?? true, - hasDOM: env && 'hasDOM' in env ? Boolean(env?.['hasDOM']) : true, - }); - RENDERER_CACHE.set(owner, renderer); - } + let renderer = rendererFor(owner, env); + let element = isCursor(into) ? into.element : into; - /** - * Replace all contents, if we've rendered multiple times. - * - * https://github.com/emberjs/rfcs/pull/1099/files#diff-2b962105b9083ca84579cdc957f27f49407440f3c5078083fa369ec18cc46da8R365 - * - * We could later add an option to not do this behavior - * - * NOTE: destruction is async - */ - let key = cacheKey(into); - let existing = RENDER_CACHE.get(key); - existing?.result.destroy(); - /** - * We can only replace the inner HTML the first time. - * Because destruction is async, it won't be safe to - * do this again, and we'll have to rely on the above destroy. - */ - if (!existing && isDOMElement(into)) { - into.innerHTML = ''; - } + let root = renderer.render(component, { into: replaceLastRender(into, element), args }); + let glimmerResult = root.result; /** - * If there's an existing render result with valid bounds, use its - * firstNode as the nextSibling so that new content is inserted at - * the same DOM position. This ensures stable ordering when multiple - * renderComponent calls target the same element and one is re-invoked - * (e.g., due to tracked dependency changes). - * - * The old content's DOM nodes are still present (destruction is async), - * so firstNode() is a valid position reference. The new content is placed - * BEFORE the old content. When the old content is eventually destroyed - * (async clear of bounds), the new content remains in the correct position. + * The destruction of the root, and not only of its Glimmer result, + * also removes the root from the renderer. + * After that, nothing holds the root or `into`. */ - let renderTarget: IntoTarget = into; - if (existing?.glimmerResult) { - let firstNode = existing.glimmerResult.firstNode(); - renderTarget = { element: key as SimpleElement, nextSibling: firstNode }; - } - - let innerResult = renderer.render(component, { into: renderTarget, args }).result; - - if (innerResult) { - associateDestroyableChild(owner, innerResult); - registerDestructor(innerResult, () => { - if (RENDER_CACHE.get(key)?.glimmerResult === innerResult) { - RENDER_CACHE.delete(key); - } - }); - } + associateDestroyableChild(owner, root); let result: RenderResult = { destroy() { - if (innerResult) { - destroy(innerResult); - } + destroy(root); }, }; - RENDER_CACHE.set(key, { result, glimmerResult: innerResult }); - - return result; -} - -const RENDER_CACHE = new WeakMap(); -const RENDERER_CACHE = new WeakMap(); - -/** - * The application seeds its `renderer:-dom` service which allows for - * router-aware resolver to resolve {{mount}} - */ -export function setRenderer(owner: object, renderer: BaseRenderer): void { - RENDERER_CACHE.set(owner, renderer); -} - -export class BaseRenderer { - static strict( - owner: object, - document: SimpleDocument | Document, - options: { isInteractive: boolean; hasDOM?: boolean } - ) { - return new BaseRenderer( - owner, - { hasDOM: hasDOM, ...options }, - document as SimpleDocument, - new ResolverImpl(), - clientBuilder - ); - } - - readonly state: RendererState; - - constructor( - owner: object, - envOptions: { isInteractive: boolean; hasDOM: boolean }, - document: SimpleDocument, - resolver: Resolver, - builder: IBuilder - ) { - let sharedArtifacts = artifacts(); + LAST_RENDER_INTO.set(element, { result, glimmerResult }); + if (glimmerResult) { /** - * SAFETY: are there consequences for being looser with *this* owner? - * the public API for `owner` is kinda `Partial` - * aka: implement only what you need. - * But for actual ember apps, you *need* to implement everything - * an app needs (which will actually change and become less over time) + * A later render into the same element can own the entry at this time. + * Delete the entry only if it still belongs to this render. */ - let env = new EmberEnvironmentDelegate(owner as InternalOwner, envOptions.isInteractive); - let options = runtimeOptions({ document }, env, sharedArtifacts, resolver); - let context = new EvaluationContextImpl( - sharedArtifacts, - (heap) => new RuntimeOpImpl(heap), - options - ); - - this.state = RendererState.create( - { - owner, - context, - builder, - }, - this - ); - } - - get debugRenderTree(): DebugRenderTree { - let { debugRenderTree } = this.state.env; - - assert( - 'Attempted to access the DebugRenderTree, but it did not exist. Is the Ember Inspector open?', - debugRenderTree - ); - - return debugRenderTree; - } - - isValid(): boolean { - return this.state.isValid(); - } - - destroy() { - destroy(this); - } - - render( - component: object, - options: { into: IntoTarget; args?: Record } - ): RendererRoot { - const root = new ComponentRootState(this.state, component, { - args: options.args, - into: intoTarget(options.into), + registerDestructor(glimmerResult, () => { + if (LAST_RENDER_INTO.get(element)?.glimmerResult === glimmerResult) { + LAST_RENDER_INTO.delete(element); + } }); - return this.state.renderRoot(root, this); } - rerender(): void { - this.state.scheduleRevalidate(this); - } + return result; } diff --git a/packages/@ember/-internals/glimmer/lib/renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer.ts index 88166168d10..3a9310e9c2c 100644 --- a/packages/@ember/-internals/glimmer/lib/renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/renderer.ts @@ -235,7 +235,7 @@ export class Renderer extends BaseRenderer { dynamicScope, this.state.builder ); - this.state.renderRoot(rootState, this); + this.state.renderRoot(rootState); } cleanupRootFor(component: ClassicComponent): void { diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts index 0bbf0471c5d..017b56bf9f0 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts @@ -16,7 +16,7 @@ import type { EngineInstanceOptions } from '@ember/engine/instance'; import type EngineInstance from '@ember/engine/instance'; import type { CapturedRenderNode } from '@glimmer/interfaces'; import { componentCapabilities, setComponentTemplate } from '@glimmer/manager'; -import { templateOnlyComponent } from '@glimmer/runtime'; +import templateOnlyComponent from '@ember/component/template-only'; import type { SimpleElement, SimpleNode } from '@simple-dom/interface'; import { precompileTemplate } from '@ember/template-compilation'; import { runTask } from 'internal-test-helpers/lib/run'; diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js b/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js index 409ba12fe02..53e2a12d0db 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/application/engine-test.js @@ -14,7 +14,7 @@ import { next } from '@ember/runloop'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; -import { templateOnlyComponent } from '@glimmer/runtime'; +import templateOnlyComponent from '@ember/component/template-only'; moduleFor( 'Application test: engine rendering', diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/angle-bracket-invocation-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/angle-bracket-invocation-test.js index 8ef0e73300a..cfd7fc0898a 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/angle-bracket-invocation-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/angle-bracket-invocation-test.js @@ -6,7 +6,7 @@ import { set, setProperties } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { Component as EmberComponent } from '../../utils/helpers'; +import PositionalComponent from '../../utils/positional-component'; import { template } from '@ember/template-compiler/runtime'; import templateOnly from '@ember/component/template-only'; import { precompileTemplate } from '@ember/template-compilation'; @@ -142,55 +142,47 @@ moduleFor( this.assertElement(this.firstChild, { tagName: 'div', content: '' }); } - '@test it can have a custom id and it is not bound'() { + '@test it renders passed named arguments'() { this.owner.register( 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{this.id}} {{this.elementId}}'), - class extends EmberComponent {} - ) + setComponentTemplate(precompileTemplate('{{@foo}}'), class extends Component {}) ); - this.render('', { - customId: 'bizz', + this.render('', { + model: { + bar: 'Hola', + }, }); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bizz bizz', - }); + this.assertText('Hola'); - this.assertStableRerender(); + runTask(() => this.rerender()); - runTask(() => set(this.context, 'customId', 'bar')); + this.assertText('Hola'); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bar bizz', - }); + runTask(() => this.context.set('model.bar', 'Hello')); - runTask(() => set(this.context, 'customId', 'bizz')); + this.assertText('Hello'); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bizz bizz', - }); + runTask(() => this.context.set('model', { bar: 'Hola' })); + + this.assertText('Hola'); } '@test it can have a custom id attribute and it is bound'() { this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) + setComponentTemplate( + precompileTemplate('
hello
'), + class extends Component {} + ) ); this.render('', { customId: 'bizz', }); - this.assertComponentElement(this.firstChild, { + this.assertElement(this.firstChild, { tagName: 'div', attrs: { id: 'bizz' }, content: 'hello', @@ -200,7 +192,7 @@ moduleFor( runTask(() => set(this.context, 'customId', 'bar')); - this.assertComponentElement(this.firstChild, { + this.assertElement(this.firstChild, { tagName: 'div', attrs: { id: 'bar' }, content: 'hello', @@ -208,282 +200,17 @@ moduleFor( runTask(() => set(this.context, 'customId', 'bizz')); - this.assertComponentElement(this.firstChild, { + this.assertElement(this.firstChild, { tagName: 'div', attrs: { id: 'bizz' }, content: 'hello', }); } - '@test it can have a custom tagName'() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('hello'), - class extends EmberComponent { - tagName = 'foo-bar'; - } - ) - ); - - this.render(''); - - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); - } - - '@test it can have a custom tagName from the invocation'() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) - ); - - this.render(''); - - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); - } - - '@test it can have custom classNames'() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('hello'), - class extends EmberComponent { - classNames = ['foo', 'bar']; - } - ) - ); - - this.render(''); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view foo bar') }, - content: 'hello', - }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view foo bar') }, - content: 'hello', - }); - } - - '@test class property on components can be dynamic'() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) - ); - - this.render('', { - fooBar: true, - }); - - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view foo-bar') }, - }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view foo-bar') }, - }); - - runTask(() => set(this.context, 'fooBar', false)); - - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view') }, - }); - - runTask(() => set(this.context, 'fooBar', true)); - - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view foo-bar') }, - }); - } - - '@test it can set custom classNames from the invocation'() { - let FooBarComponent = class extends EmberComponent { - classNames = ['foo']; - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); - - this.render(strip` - - - - `); - - this.assertComponentElement(this.nthChild(0), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(1), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(2), { - tagName: 'div', - attrs: { class: classes('ember-view foo') }, - content: 'hello', - }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.nthChild(0), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(1), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(2), { - tagName: 'div', - attrs: { class: classes('ember-view foo') }, - content: 'hello', - }); - } - - '@test it has an element'() { - let instance; - - let FooBarComponent = class extends EmberComponent { - init() { - super.init(); - instance = this; - } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); - - this.render(''); - - let element1 = instance.element; - - this.assertComponentElement(element1, { content: 'hello' }); - - runTask(() => this.rerender()); - - let element2 = instance.element; - - this.assertComponentElement(element2, { content: 'hello' }); - - this.assertSameNode(element2, element1); - } - - '@test it has the right parentView and childViews'(assert) { - let fooBarInstance, fooBarBazInstance; - - let FooBarComponent = class extends EmberComponent { - init() { - super.init(); - fooBarInstance = this; - } - }; - - let FooBarBazComponent = class extends EmberComponent { - init() { - super.init(); - fooBarBazInstance = this; - } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('foo-bar {{foo-bar-baz}}'), FooBarComponent) - ); - this.owner.register( - 'component:foo-bar-baz', - setComponentTemplate(precompileTemplate('foo-bar-baz'), FooBarBazComponent) - ); - - this.render(''); - this.assertText('foo-bar foo-bar-baz'); - - assert.equal(fooBarInstance.parentView, this.component); - assert.equal(fooBarBazInstance.parentView, fooBarInstance); - - assert.deepEqual(this.component.childViews, [fooBarInstance]); - assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); - - runTask(() => this.rerender()); - this.assertText('foo-bar foo-bar-baz'); - - assert.equal(fooBarInstance.parentView, this.component); - assert.equal(fooBarBazInstance.parentView, fooBarInstance); - - assert.deepEqual(this.component.childViews, [fooBarInstance]); - assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); - } - - '@test it renders passed named arguments'() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{@foo}}'), class extends Component {}) - ); - - this.render('', { - model: { - bar: 'Hola', - }, - }); - - this.assertText('Hola'); - - runTask(() => this.rerender()); - - this.assertText('Hola'); - - runTask(() => this.context.set('model.bar', 'Hello')); - - this.assertText('Hello'); - - runTask(() => this.context.set('model', { bar: 'Hola' })); - - this.assertText('Hola'); - } - - '@test it reflects named arguments as properties'() { + '@test it reflects named arguments on `this.args`'() { this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.foo}}'), class extends EmberComponent {}) + setComponentTemplate(precompileTemplate('{{this.args.foo}}'), class extends Component {}) ); this.render('', { @@ -585,7 +312,7 @@ moduleFor( } '@test positional parameters are not allowed'() { - let TestComponent = class extends EmberComponent { + let TestComponent = class extends PositionalComponent { static positionalParams = ['first', 'second']; }; @@ -704,54 +431,6 @@ moduleFor( this.assertStableRerender(); } - '@test includes invocation specified attributes in root element ("splattributes")'() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) - ); - - this.render('', { - foo: 'foo', - bar: 'bar', - }); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, - content: 'hello', - }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, - content: 'hello', - }); - - runTask(() => { - set(this.context, 'foo', 'FOO'); - set(this.context, 'bar', undefined); - }); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { 'data-foo': 'FOO' }, - content: 'hello', - }); - - runTask(() => { - set(this.context, 'foo', 'foo'); - set(this.context, 'bar', 'bar'); - }); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, - content: 'hello', - }); - } - '@test attributes without values passed at invocation are included in `...attributes` ("splattributes")'() { this.owner.register( 'component:foo-bar', diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/attribute-bindings-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/attribute-bindings-test.js index 137d31497f6..93eb48a58fb 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/attribute-bindings-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/attribute-bindings-test.js @@ -994,5 +994,79 @@ moduleFor( this.assert.ok(this.nthChild(0).id.match(/ember\d+/), 'a valid `id` was used'); this.assert.ok(this.nthChild(1).id.match(/ember\d+/), 'a valid `id` was used'); } + + '@test setting a value attributeBinding to null results in an empty string value'(assert) { + this.owner.register( + 'component:input-component', + setComponentTemplate( + precompileTemplate('input component'), + class extends Component { + tagName = 'input'; + attributeBindings = ['value']; + } + ) + ); + + this.render('{{input-component value=this.someProp}}', { someProp: null }); + + let element = this.firstChild; + assert.strictEqual(element.value, ''); + + runTask(() => set(this.context, 'someProp', 'wycats')); + assert.strictEqual(element.value, 'wycats'); + + runTask(() => set(this.context, 'someProp', null)); + assert.strictEqual(element.value, ''); + } + + // https://github.com/emberjs/ember.js/issues/15675 + renderRange(value) { + this.owner.register( + 'component:range-input', + setComponentTemplate( + precompileTemplate(''), + class extends Component { + tagName = 'input'; + type = 'range'; + attributeBindings = ['type', 'value', 'min', 'max']; + } + ) + ); + + this.render('{{range-input max=this.max min=this.min value=this.value}}', { + max: 50, + min: -5, + value, + }); + } + + '@test [emberjs/ember.js#15675] range input: value over the default max but below the set max is kept'( + assert + ) { + this.renderRange(25); + assert.strictEqual(this.firstChild.value, '25'); + } + + '@test [emberjs/ember.js#15675] range input: value below the default min but above the set min is kept'( + assert + ) { + this.renderRange(-2); + assert.strictEqual(this.firstChild.value, '-2'); + } + + '@test [emberjs/ember.js#15675] range input: value in the valid default range is kept'(assert) { + this.renderRange(5); + assert.strictEqual(this.firstChild.value, '5'); + } + + '@test [emberjs/ember.js#15675] range input: value above max is reset to max'(assert) { + this.renderRange(55); + assert.strictEqual(this.firstChild.value, '50'); + } + + '@test [emberjs/ember.js#15675] range input: value below min is reset to min'(assert) { + this.renderRange(-10); + assert.strictEqual(this.firstChild.value, '-5'); + } } ); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/attrs-lookup-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/attrs-lookup-test.js index 631b532e336..451f7734ecd 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/attrs-lookup-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/attrs-lookup-test.js @@ -171,9 +171,7 @@ moduleFor( } }; - FooBarComponent.reopenClass({ - positionalParams: ['firstPositional'], - }); + FooBarComponent.positionalParams = ['firstPositional']; this.owner.register('component:foo-bar', FooBarComponent); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/angle-bracket-invocation-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/angle-bracket-invocation-test.js new file mode 100644 index 00000000000..772f2ae6d8d --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/angle-bracket-invocation-test.js @@ -0,0 +1,543 @@ +import { moduleFor, RenderingTestCase, strip, classes, runTask } from 'internal-test-helpers'; +import { setComponentTemplate } from '@glimmer/manager'; +import { set } from '@ember/object'; +import { Component as EmberComponent } from '../../../utils/helpers'; +import { precompileTemplate } from '@ember/template-compilation'; + +moduleFor( + 'AngleBracket Invocation (classic component)', + class extends RenderingTestCase { + '@test it can have a custom id and it is not bound'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.id}} {{this.elementId}}'), + class extends EmberComponent {} + ) + ); + + this.render('', { + customId: 'bizz', + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'customId', 'bar')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bar bizz', + }); + + runTask(() => set(this.context, 'customId', 'bizz')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + } + + '@test it can have a custom id attribute and it is bound'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) + ); + + this.render('', { + customId: 'bizz', + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'hello', + }); + + this.assertStableRerender(); + + runTask(() => set(this.context, 'customId', 'bar')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bar' }, + content: 'hello', + }); + + runTask(() => set(this.context, 'customId', 'bizz')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'hello', + }); + } + + '@test it can have a custom tagName'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('hello'), + class extends EmberComponent { + tagName = 'foo-bar'; + } + ) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + '@test it can have a custom tagName from the invocation'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + '@test it can have custom classNames'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('hello'), + class extends EmberComponent { + classNames = ['foo', 'bar']; + } + ) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar') }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar') }, + content: 'hello', + }); + } + + '@test class property on components can be dynamic'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) + ); + + this.render('', { + fooBar: true, + }); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + + runTask(() => set(this.context, 'fooBar', false)); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view') }, + }); + + runTask(() => set(this.context, 'fooBar', true)); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + } + + '@test it can set custom classNames from the invocation'() { + let FooBarComponent = class extends EmberComponent { + classNames = ['foo']; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render(strip` + + + + `); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(2), { + tagName: 'div', + attrs: { class: classes('ember-view foo') }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(2), { + tagName: 'div', + attrs: { class: classes('ember-view foo') }, + content: 'hello', + }); + } + + '@test it has an element'() { + let instance; + + let FooBarComponent = class extends EmberComponent { + init() { + super.init(); + instance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render(''); + + let element1 = instance.element; + + this.assertComponentElement(element1, { content: 'hello' }); + + runTask(() => this.rerender()); + + let element2 = instance.element; + + this.assertComponentElement(element2, { content: 'hello' }); + + this.assertSameNode(element2, element1); + } + + '@test it has the right parentView and childViews'(assert) { + let fooBarInstance, fooBarBazInstance; + + let FooBarComponent = class extends EmberComponent { + init() { + super.init(); + fooBarInstance = this; + } + }; + + let FooBarBazComponent = class extends EmberComponent { + init() { + super.init(); + fooBarBazInstance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('foo-bar {{foo-bar-baz}}'), FooBarComponent) + ); + this.owner.register( + 'component:foo-bar-baz', + setComponentTemplate(precompileTemplate('foo-bar-baz'), FooBarBazComponent) + ); + + this.render(''); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + + runTask(() => this.rerender()); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + } + + '@test it reflects named arguments as properties'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.foo}}'), class extends EmberComponent {}) + ); + + this.render('', { + model: { + bar: 'Hola', + }, + }); + + this.assertText('Hola'); + + runTask(() => this.rerender()); + + this.assertText('Hola'); + + runTask(() => this.context.set('model.bar', 'Hello')); + + this.assertText('Hello'); + + runTask(() => this.context.set('model', { bar: 'Hola' })); + + this.assertText('Hola'); + } + + '@test positional parameters are not allowed'() { + let TestComponent = class extends EmberComponent { + static positionalParams = ['first', 'second']; + }; + + this.owner.register( + 'component:sample-component', + setComponentTemplate(precompileTemplate('{{this.first}}{{this.second}}'), TestComponent) + ); + + // this is somewhat silly as the browser "corrects" for these as + // attribute names, but regardless the thing we care about here is that + // they are **not** used as positional params + this.render(''); + + this.assertText(''); + } + + '@test includes invocation specified attributes in root element ("splattributes")'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends EmberComponent {}) + ); + + this.render('', { + foo: 'foo', + bar: 'bar', + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + runTask(() => { + set(this.context, 'foo', 'FOO'); + set(this.context, 'bar', undefined); + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'FOO' }, + content: 'hello', + }); + + runTask(() => { + set(this.context, 'foo', 'foo'); + set(this.context, 'bar', 'bar'); + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + } + + '@test invocation attributes are merged with the classes of the component'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('Hello world!'), + class extends EmberComponent { + classNames = ['inner']; + } + ) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view inner outer') }, + content: 'Hello world!', + }); + + this.assertStableRerender(); + } + + '@test invocation attributes win over attributeBindings'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('Hello world!'), + class extends EmberComponent { + attributeBindings = ['data-foo']; + 'data-foo' = 'inner'; + } + ) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'outer' }, + content: 'Hello world!', + }); + + this.assertStableRerender(); + } + + '@test explicit ...attributes in the layout receive the invocation attributes'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('

Hello world!

'), + class extends EmberComponent {} + ) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'bar' }, + content: '

Hello world!

', + }); + + this.assertStableRerender(); + } + + '@test it can yield to a named block'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('Hello{{yield to="baz"}}world!'), + class extends EmberComponent {} + ) + ); + + this.render('<:baz> my '); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + content: 'Hello my world!', + }); + + this.assertStableRerender(); + } + + '@test it can yield to an explicit default named block'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('Hello{{yield to="default"}}world!'), + class extends EmberComponent {} + ) + ); + + this.render('<:default> my '); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + content: 'Hello my world!', + }); + + this.assertStableRerender(); + } + + '@test it can yield a value to the else and inverse named blocks'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('Hello{{yield "my" to="inverse"}}world!'), + class extends EmberComponent {} + ) + ); + + this.render(strip` + <:else as |value|> {{value}} + <:inverse as |value|> {{value}} + `); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + content: 'Hello my world!', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + content: 'Hello my world!', + }); + + this.assertStableRerender(); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/append-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/append-test.js similarity index 99% rename from packages/@ember/-internals/glimmer/tests/integration/components/append-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/classic/append-test.js index fd5a5030aa4..5a59e63b4d0 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/append-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/append-test.js @@ -5,13 +5,13 @@ import { runTask, expectDeprecation, } from 'internal-test-helpers'; -import { DEPRECATIONS } from '../../../../deprecations'; +import { DEPRECATIONS } from '../../../../../deprecations'; import { set } from '@ember/object'; import { setComponentTemplate } from '@glimmer/manager'; import { precompileTemplate } from '@ember/template-compilation'; -import { Component } from '../../utils/helpers'; +import { Component } from '../../../utils/helpers'; class AbstractAppendTest extends RenderingTestCase { constructor() { diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/application-lifecycle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/application-lifecycle-test.js new file mode 100644 index 00000000000..679e2401aa1 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/application-lifecycle-test.js @@ -0,0 +1,91 @@ +import { moduleFor, AutobootApplicationTestCase, runTask } from 'internal-test-helpers'; +import Application from '@ember/application'; +import Router from '@ember/routing/router'; +import { Component } from '@ember/-internals/glimmer'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +moduleFor( + 'Application Lifecycle (classic component)', + class extends AutobootApplicationTestCase { + createApplication() { + let application = super.createApplication(...arguments); + this.add( + 'router:main', + class extends Router { + location = 'none'; + } + ); + return application; + } + + [`@test initializers can augment an applications customEvents hash`](assert) { + assert.expect(1); + + let MyApplication = class extends Application {}; + + MyApplication.initializer({ + name: 'customize-things', + initialize(application) { + application.customEvents = { + wowza: 'wowza', + }; + }, + }); + + runTask(() => { + this.createApplication({}, MyApplication); + + this.add( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate(`
`), + class extends Component { + wowza() { + assert.ok(true, 'fired the event!'); + } + } + ) + ); + + this.add('template:application', precompileTemplate(`{{foo-bar}}`)); + }); + + this.$('#wowza-thingy').trigger('wowza'); + } + + [`@test instanceInitializers can augment an the customEvents hash`](assert) { + assert.expect(1); + + let MyApplication = class extends Application {}; + + MyApplication.instanceInitializer({ + name: 'customize-things', + initialize(application) { + application.customEvents = { + herky: 'jerky', + }; + }, + }); + runTask(() => { + this.createApplication({}, MyApplication); + + this.add( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate(`
`), + class extends Component { + jerky() { + assert.ok(true, 'fired the event!'); + } + } + ) + ); + + this.add('template:application', precompileTemplate(`{{foo-bar}}`)); + }); + + this.$('#herky-thingy').trigger('herky'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-context-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-context-test.js new file mode 100644 index 00000000000..8f73184b48e --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-context-test.js @@ -0,0 +1,107 @@ +import Controller from '@ember/controller'; +import { Component as EmberComponent } from '@ember/-internals/glimmer'; +import { precompileTemplate } from '@ember/template-compilation'; +import { moduleFor, ApplicationTestCase, getTextOf } from 'internal-test-helpers'; + +moduleFor( + 'Application Lifecycle - Component Context (classic component)', + class extends ApplicationTestCase { + ['@test Components without a block should have the proper content'](assert) { + this.add( + 'template:application', + precompileTemplate( + ` +
{{my-component}}
+ ` + ) + ); + + this.add( + 'controller:application', + class extends Controller { + text = 'outer'; + } + ); + this.add( + 'component:my-component', + class extends EmberComponent { + didInsertElement() { + this.element.innerHTML = 'Some text inserted'; + } + } + ); + + return this.visit('/').then(() => { + let text = getTextOf(this.element.querySelector('#wrapper')); + assert.equal(text, 'Some text inserted', 'The component is composed correctly'); + }); + } + + ['@test properties of a component without a template should not collide with internal structures [DEPRECATED]']( + assert + ) { + this.add( + 'template:application', + precompileTemplate( + ` +
{{my-component data=this.foo}}
` + ) + ); + + this.add( + 'controller:application', + class extends Controller { + text = 'outer'; + foo = 'Some text inserted'; + } + ); + this.add( + 'component:my-component', + class extends EmberComponent { + didInsertElement() { + this.element.innerHTML = this.get('data'); + } + } + ); + + return this.visit('/').then(() => { + let text = getTextOf(this.element.querySelector('#wrapper')); + assert.equal(text, 'Some text inserted', 'The component is composed correctly'); + }); + } + + ['@test attrs property of a component without a template should not collide with internal structures']( + assert + ) { + this.add( + 'template:application', + precompileTemplate( + ` +
{{my-component attrs=this.foo}}
+ ` + ) + ); + + this.add( + 'controller:application', + class extends Controller { + text = 'outer'; + foo = 'Some text inserted'; + } + ); + this.add( + 'component:my-component', + class extends EmberComponent { + didInsertElement() { + this.element.innerHTML = this.get('attrs.attrs.value'); + } + } + ); + + return this.visit('/').then(() => { + let text = getTextOf(this.element.querySelector('#wrapper')); + assert.equal(text, 'Some text inserted', 'The component is composed correctly'); + }); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-registration-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-registration-test.js new file mode 100644 index 00000000000..e1afc0f8a18 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-registration-test.js @@ -0,0 +1,127 @@ +import Application from '@ember/application'; +import Controller from '@ember/controller'; +import { Component } from '@ember/-internals/glimmer'; +import { precompileTemplate } from '@ember/template-compilation'; +import { moduleFor, ApplicationTestCase } from 'internal-test-helpers'; + +moduleFor( + 'Application Lifecycle - Component Registration (classic component)', + class extends ApplicationTestCase { + // This is necessary for this.application.instanceInitializer to not leak between tests + createApplication(options) { + return super.createApplication(options, class extends Application {}); + } + + ['@test Late-registered components can be rendered with custom `layout` property'](assert) { + this.add( + 'template:application', + precompileTemplate(`
there goes {{my-hero}}
`) + ); + + this.application.instanceInitializer({ + name: 'my-hero-component', + initialize(applicationInstance) { + applicationInstance.register( + 'component:my-hero', + class extends Component { + classNames = ['testing123']; + layout = precompileTemplate('watch him as he GOES'); + } + ); + }, + }); + + return this.visit('/').then(() => { + let text = this.$('#wrapper').text().trim(); + assert.equal( + text, + 'there goes watch him as he GOES', + 'The component is composed correctly' + ); + }); + } + + ['@test Assigning layoutName to a component should setup the template as a layout'](assert) { + assert.expect(1); + + this.add( + 'template:application', + precompileTemplate( + `
{{#my-component}}{{this.text}}{{/my-component}}
` + ) + ); + this.add('template:foo-bar-baz', precompileTemplate('{{this.text}}-{{yield}}')); + + this.application.instanceInitializer({ + name: 'application-controller', + initialize(applicationInstance) { + applicationInstance.register( + 'controller:application', + class extends Controller { + text = 'outer'; + } + ); + }, + }); + this.application.instanceInitializer({ + name: 'my-component-component', + initialize(applicationInstance) { + applicationInstance.register( + 'component:my-component', + class extends Component { + text = 'inner'; + layoutName = 'foo-bar-baz'; + } + ); + }, + }); + + return this.visit('/').then(() => { + let text = this.$('#wrapper').text().trim(); + assert.equal(text, 'inner-outer', 'The component is composed correctly'); + }); + } + + ['@test Assigning layoutName and layout to a component should use the `layout` value'](assert) { + assert.expect(1); + + this.add( + 'template:application', + precompileTemplate( + `
{{#my-component}}{{this.text}}{{/my-component}}
` + ) + ); + this.add('template:foo-bar-baz', precompileTemplate('No way!')); + + this.application.instanceInitializer({ + name: 'application-controller-layout', + initialize(applicationInstance) { + applicationInstance.register( + 'controller:application', + class extends Controller { + text = 'outer'; + } + ); + }, + }); + this.application.instanceInitializer({ + name: 'my-component-component-layout', + initialize(applicationInstance) { + applicationInstance.register( + 'component:my-component', + class extends Component { + text = 'inner'; + layoutName = 'foo-bar-baz'; + layout = precompileTemplate('{{this.text}}-{{yield}}'); + } + ); + }, + }); + + return this.visit('/').then(() => { + let text = this.$('#wrapper').text().trim(); + assert.equal(text, 'inner-outer', 'The component is composed correctly'); + }); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-template-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-template-test.js new file mode 100644 index 00000000000..6d6ccf6cbbf --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/component-template-test.js @@ -0,0 +1,26 @@ +import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; +import { setComponentTemplate } from '@glimmer/manager'; +import { precompileTemplate } from '@ember/template-compilation'; +import { Component as EmberComponent } from '../../../utils/helpers'; + +moduleFor( + 'Components test: setComponentTemplate (classic component)', + class extends RenderingTestCase { + '@test templates set with setComponentTemplate are inherited (EmberObject.extend())'() { + let Parent = setComponentTemplate( + precompileTemplate('hello'), + class extends EmberComponent {} + ); + + this.owner.register('component:foo-bar', class extends Parent {}); + + this.render(''); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/content-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/content-test.js new file mode 100644 index 00000000000..02db6b6d981 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/content-test.js @@ -0,0 +1,56 @@ +import { DEBUG } from '@glimmer/env'; + +import { RenderingTestCase, moduleFor } from 'internal-test-helpers'; + +import { getDebugFunction, setDebugFunction } from '@ember/debug'; +import { constructStyleDeprecationMessage } from '@ember/-internals/views'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; +import { Component } from '../../../utils/helpers'; + +let warnings, originalWarn; +class StyleTest extends RenderingTestCase { + constructor() { + super(...arguments); + warnings = []; + originalWarn = getDebugFunction('warn'); + setDebugFunction('warn', function (message, test) { + if (!test) { + warnings.push(message); + } + }); + } + + teardown() { + super.teardown(...arguments); + setDebugFunction('warn', originalWarn); + } + + assertStyleWarning(style) { + this.assert.deepEqual(warnings, [constructStyleDeprecationMessage(style)]); + } +} + +if (DEBUG) { + moduleFor( + 'Inline style tests - warnings (classic component)', + class extends StyleTest { + ['@test specifying `attributeBindings: ["style"]` generates a warning']() { + let FooBarComponent = class extends Component { + attributeBindings = ['style']; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + let userValue = 'width: 42px'; + this.render('{{foo-bar style=this.userValue}}', { + userValue, + }); + + this.assertStyleWarning(userValue); + } + } + ); +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/contextual-components-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/contextual-components-test.js new file mode 100644 index 00000000000..cae118daefc --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/contextual-components-test.js @@ -0,0 +1,458 @@ +import { moduleFor, RenderingTestCase, applyMixins, strip, runTask } from 'internal-test-helpers'; +import { action } from '@ember/object'; +import { trackedArray } from '@ember/reactive/collections'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { Component as EmberComponent } from '../../../utils/helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +moduleFor( + 'Components test: contextual components (classic component)', + class extends RenderingTestCase { + ['@test conflicting positional and hash parameters does not raise an assertion if rerendered']() { + // In some cases, rerendering with a positional param used to cause an + // assertion. This test checks it does not. + this.owner.register( + 'component:-looked-up', + setComponentTemplate( + precompileTemplate('{{this.greeting}} {{this.name}}'), + class extends EmberComponent { + static positionalParams = ['name']; + } + ) + ); + + this.render('{{component (component "-looked-up" this.model.name greeting="Hodi")}}', { + model: { + name: 'Hodari', + }, + }); + + this.assertText('Hodi Hodari'); + + runTask(() => this.rerender()); + + this.assertText('Hodi Hodari'); + + runTask(() => this.context.set('model.name', 'Sergio')); + + this.assertText('Hodi Sergio'); + + runTask(() => this.context.set('model', { name: 'Hodari' })); + + this.assertText('Hodi Hodari'); + } + + ['@test renders with dot path and rest parameter does not leak'](assert) { + // In the original implementation, positional parameters were not handled + // correctly causing the first positional parameter to be the contextual + // component itself. + let value = false; + + this.owner.register( + 'component:my-component', + class extends EmberComponent { + static positionalParams = ['value']; + + didReceiveAttrs() { + value = this.getAttr('value'); + } + } + ); + + this.render( + strip` + {{#let (hash my-component=(component 'my-component' this.first)) as |c|}} + {{c.my-component}} + {{/let}}`, + { first: 'first' } + ); + + assert.equal(value, 'first', 'value is the expected parameter'); + } + + ['@test renders with dot path and updates attributes'](assert) { + this.owner.register( + 'component:my-nested-component', + setComponentTemplate( + precompileTemplate('{{this.myProp}}'), + class extends EmberComponent { + static positionalParams = ['my-parent-attr']; + + didReceiveAttrs() { + this.set('myProp', this.getAttr('my-parent-attr')); + } + } + ) + ); + + this.owner.register( + 'component:my-component', + setComponentTemplate( + precompileTemplate( + '{{yield (hash my-nested-component=(component "my-nested-component" my-parent-attr=@my-attr))}}' + ), + class extends Component {} + ) + ); + + this.owner.register( + 'component:my-action-component', + setComponentTemplate( + precompileTemplate( + '{{#my-component my-attr=this.myProp as |api|}}{{api.my-nested-component}}{{/my-component}}
' + ), + class extends EmberComponent { + @action + changeValue() { + this.incrementProperty('myProp'); + } + } + ) + ); + + this.render('{{my-action-component myProp=this.model.myProp}}', { + model: { + myProp: 1, + }, + }); + + assert.equal(this.$('#nested-prop').text(), '1'); + + runTask(() => this.rerender()); + + assert.equal(this.$('#nested-prop').text(), '1'); + + runTask(() => this.$('button').click()); + + assert.equal(this.$('#nested-prop').text(), '2'); + + runTask(() => this.$('button').click()); + + assert.equal(this.$('#nested-prop').text(), '3'); + + runTask(() => this.context.set('model', { myProp: 1 })); + + assert.equal(this.$('#nested-prop').text(), '1'); + } + + ['@test parameters in a contextual component are mutable when value is a param'](assert) { + // This checks that a `(mut)` is added to parameters and attributes to + // contextual components when it is a param. + this.owner.register( + 'component:change-button', + setComponentTemplate( + precompileTemplate( + '' + ), + class extends EmberComponent { + static positionalParams = ['val']; + } + ) + ); + + this.render( + strip` + {{component (component "change-button" this.model.val2)}} + {{this.model.val2}}`, + { + model: { + val2: 8, + }, + } + ); + + assert.equal(this.$('.value').text(), '8'); + + runTask(() => this.rerender()); + + assert.equal(this.$('.value').text(), '8'); + + runTask(() => this.$('.my-button').click()); + + assert.equal(this.$('.value').text(), '10'); + + runTask(() => this.context.set('model', { val2: 8 })); + + assert.equal(this.$('.value').text(), '8'); + } + + ['@test tagless blockless components render'](assert) { + this.owner.register( + 'component:my-comp', + class extends EmberComponent { + tagName = ''; + } + ); + + this.render(`{{my-comp}}`); + + runTask(() => this.rerender()); + + assert.equal(this.$().text(), ''); + } + + ['@test GH#13494 tagless blockless component with property binding'](assert) { + this.owner.register( + 'component:outer-component', + setComponentTemplate( + precompileTemplate( + 'message: {{this.message}}{{inner-component message=this.message}}' + ), + class extends EmberComponent { + static positionalParams = ['val']; + } + ) + ); + + setup.call(this, assert); + + assert.equal(this.$('.value').text(), '8'); + + runTask(() => this.rerender()); + + assert.equal(this.$('.value').text(), '8'); + + runTask(() => this.$('.my-button').click()); + + assert.equal(this.$('.value').text(), '10'); + + runTask(() => this.context.set('model', { val2: 8 })); + + assert.equal(this.$('.value').text(), '8'); + }, + }; + } +} + +applyMixins( + ContextualComponentMutableParamsTest, + new MutableParamTestGenerator([ + { + title: 'param', + setup() { + this.render('{{component (component "change-button" this.model.val2)}}'); + }, + }, + + { + title: 'nested param', + setup() { + this.owner.register( + 'component:my-comp', + setComponentTemplate( + precompileTemplate('{{component this.components.comp}}'), + class extends EmberComponent { + static positionalParams = ['components']; + } + ) + ); + + this.render('{{my-comp (hash comp=(component "change-button" this.model.val2))}}'); + }, + }, + ]) +); + +moduleFor( + 'Components test: contextual components -- mutable params (classic component)', + ContextualComponentMutableParamsTest +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/curly-components-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/curly-components-test.js new file mode 100644 index 00000000000..5b4ac8e7fad --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/curly-components-test.js @@ -0,0 +1,2477 @@ +import { + moduleFor, + RenderingTestCase, + strip, + classes, + runTask, + runLoopSettled, + expectDeprecation, + testUnless, +} from 'internal-test-helpers'; +import { DEPRECATIONS } from '../../../../../deprecations'; +import { action } from '@ember/object'; +import { run } from '@ember/runloop'; +import { DEBUG } from '@glimmer/env'; +import { tracked } from '@ember/-internals/metal'; +import { on } from '@ember/object/evented'; +import { service } from '@ember/service'; +import { set, get, computed, observer } from '@ember/object'; +import { trackedArray } from '@ember/reactive/collections'; +import { Component } from '../../../utils/helpers'; +import { template } from '@ember/template-compiler/runtime'; +import { setComponentTemplate } from '@glimmer/manager'; +import { precompileTemplate } from '@ember/template-compilation'; + +moduleFor( + 'Components test: curly components (classic component)', + class extends RenderingTestCase { + ['@test it can have a custom id and it is not bound']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.id}} {{this.elementId}}'), + class extends Component {} + ) + ); + + this.render('{{foo-bar id=this.customId}}', { + customId: 'bizz', + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + + runTask(() => set(this.context, 'customId', 'bar')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bar bizz', + }); + + runTask(() => set(this.context, 'customId', 'bizz')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + } + + ['@test elementId cannot change'](assert) { + let component; + let FooBarComponent = class extends Component { + elementId = 'blahzorz'; + init() { + super.init(...arguments); + component = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.elementId}}'), FooBarComponent) + ); + + this.render('{{foo-bar}}'); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'blahzorz' }, + content: 'blahzorz', + }); + + if (DEBUG) { + let willThrow = () => run(null, set, component, 'elementId', 'herpyderpy'); + + assert.throws(willThrow, /Changing a view's elementId after creation is not allowed/); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'blahzorz' }, + content: 'blahzorz', + }); + } + } + + ['@test elementId is stable when other values change']() { + let changingArg = 'arbitrary value'; + let parentInstance; + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{quux-baz elementId="stable-id" changingArg=this.changingArg}}'), + class extends Component { + init() { + super.init(...arguments); + parentInstance = this; + } + changingArg = changingArg; + } + ) + ); + + this.owner.register( + 'component:quux-baz', + setComponentTemplate(precompileTemplate('{{this.changingArg}}'), class extends Component {}) + ); + + this.render('{{foo-bar}}'); + this.assertComponentElement(this.firstChild.firstChild, { + attrs: { id: 'stable-id' }, + content: 'arbitrary value', + }); + + changingArg = 'a different value'; + runTask(() => set(parentInstance, 'changingArg', changingArg)); + this.assertComponentElement(this.firstChild.firstChild, { + attrs: { id: 'stable-id' }, + content: changingArg, + }); + } + + ['@test can specify template with `layoutName` property']() { + let FooBarComponent = class extends Component { + elementId = 'blahzorz'; + layoutName = 'fizz-bar'; + init() { + super.init(...arguments); + this.local = 'hey'; + } + }; + + this.registerTemplate('fizz-bar', `FIZZ BAR {{this.local}}`); + + this.owner.register('component:foo-bar', FooBarComponent); + + this.render('{{foo-bar}}'); + + this.assertText('FIZZ BAR hey'); + } + + ['@test layout supports computed property']() { + let FooBarComponent = class extends Component { + elementId = 'blahzorz'; + @computed + get layout() { + return precompileTemplate('so much layout wat {{this.lulz}}'); + } + init() { + super.init(...arguments); + this.lulz = 'heyo'; + } + }; + + this.owner.register('component:foo-bar', FooBarComponent); + + this.render('{{foo-bar}}'); + + this.assertText('so much layout wat heyo'); + } + + ['@test passing undefined elementId results in a default elementId'](assert) { + let FooBarComponent = class extends Component { + tagName = 'h1'; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('something'), FooBarComponent) + ); + + this.render('{{foo-bar id=this.somethingUndefined}}'); + + let foundId = this.$('h1').attr('id'); + assert.ok( + /^ember/.test(foundId), + 'Has a reasonable id attribute (found id=' + foundId + ').' + ); + + runTask(() => this.rerender()); + + let newFoundId = this.$('h1').attr('id'); + assert.ok( + /^ember/.test(newFoundId), + 'Has a reasonable id attribute (found id=' + newFoundId + ').' + ); + + assert.equal(foundId, newFoundId); + } + + ['@test id is an alias for elementId'](assert) { + let FooBarComponent = class extends Component { + tagName = 'h1'; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('something'), FooBarComponent) + ); + + this.render('{{foo-bar id="custom-id"}}'); + + let foundId = this.$('h1').attr('id'); + assert.equal(foundId, 'custom-id'); + + runTask(() => this.rerender()); + + let newFoundId = this.$('h1').attr('id'); + assert.equal(newFoundId, 'custom-id'); + + assert.equal(foundId, newFoundId); + } + + ['@test cannot pass both id and elementId at the same time']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate(''), class extends Component {}) + ); + + expectAssertion(() => { + this.render('{{foo-bar id="zomg" elementId="lol"}}'); + }, /You cannot invoke a component with both 'id' and 'elementId' at the same time./); + } + + ['@test it can have a custom tagName']() { + let FooBarComponent = class extends Component { + tagName = 'foo-bar'; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{foo-bar}}'); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + ['@test it can have a custom tagName set in the constructor']() { + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + this.tagName = 'foo-bar'; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{foo-bar}}'); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + ['@test it can have a custom tagName from the invocation']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + ); + + this.render('{{foo-bar tagName="foo-bar"}}'); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + ['@test tagName can not be a computed property']() { + let FooBarComponent = class extends Component { + @computed + get tagName() { + return 'foo-bar'; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + expectAssertion(() => { + this.render('{{foo-bar}}'); + }, /You cannot use a computed property for the component's `tagName` \(<.+?>\)\./); + } + + ['@test class is applied before didInsertElement'](assert) { + let componentClass; + let FooBarComponent = class extends Component { + didInsertElement() { + componentClass = this.element.className; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{foo-bar class="foo-bar"}}'); + + assert.equal(componentClass, 'foo-bar ember-view'); + } + + ['@test it can have custom classNames']() { + let FooBarComponent = class extends Component { + classNames = ['foo', 'bar']; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{foo-bar}}'); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar') }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar') }, + content: 'hello', + }); + } + + ['@test should not apply falsy class name']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + ); + + this.render('{{foo-bar class=this.somethingFalsy}}', { + somethingFalsy: false, + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: 'ember-view' }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: 'ember-view' }, + content: 'hello', + }); + } + + ['@test should update class using inline if, initially false, no alternate']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + ); + + this.render('{{foo-bar class=(if this.predicate "thing") }}', { + predicate: false, + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: 'ember-view' }, + content: 'hello', + }); + + runTask(() => set(this.context, 'predicate', true)); + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view thing') }, + content: 'hello', + }); + + runTask(() => set(this.context, 'predicate', false)); + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: 'ember-view' }, + content: 'hello', + }); + } + + ['@test should update class using inline if, initially true, no alternate']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + ); + + this.render('{{foo-bar class=(if this.predicate "thing") }}', { + predicate: true, + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view thing') }, + content: 'hello', + }); + + runTask(() => set(this.context, 'predicate', false)); + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: 'ember-view' }, + content: 'hello', + }); + + runTask(() => set(this.context, 'predicate', true)); + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view thing') }, + content: 'hello', + }); + } + + ['@test class property on components can be dynamic']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + ); + + this.render('{{foo-bar class=(if this.fooBar "foo-bar")}}', { + fooBar: true, + }); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + + runTask(() => set(this.context, 'fooBar', false)); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view') }, + }); + + runTask(() => set(this.context, 'fooBar', true)); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + } + + ['@test it can have custom classNames from constructor']() { + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + this.classNames = this.classNames.slice(); + this.classNames.push('foo', 'bar', `outside-${this.get('extraClass')}`); + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{foo-bar extraClass="baz"}}'); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar outside-baz') }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar outside-baz') }, + content: 'hello', + }); + } + + ['@test it can set custom classNames from the invocation']() { + let FooBarComponent = class extends Component { + classNames = ['foo']; + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render(strip` + {{foo-bar class="bar baz"}} + {{foo-bar classNames="bar baz"}} + {{foo-bar}} + `); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(2), { + tagName: 'div', + attrs: { class: classes('ember-view foo') }, + content: 'hello', + }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(2), { + tagName: 'div', + attrs: { class: classes('ember-view foo') }, + content: 'hello', + }); + } + + ['@test it has an element']() { + let instance; + + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + instance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{foo-bar}}'); + + let element1 = instance.element; + + this.assertComponentElement(element1, { content: 'hello' }); + + runTask(() => this.rerender()); + + let element2 = instance.element; + + this.assertComponentElement(element2, { content: 'hello' }); + + this.assertSameNode(element2, element1); + } + + ['@test an empty component does not have childNodes'](assert) { + let fooBarInstance; + let FooBarComponent = class extends Component { + tagName = 'input'; + init() { + super.init(...arguments); + fooBarInstance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate(''), FooBarComponent) + ); + + this.render('{{foo-bar}}'); + + this.assertComponentElement(this.firstChild, { tagName: 'input' }); + + assert.strictEqual(fooBarInstance.element.childNodes.length, 0); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { tagName: 'input' }); + + assert.strictEqual(fooBarInstance.element.childNodes.length, 0); + } + + ['@test it has the right parentView and childViews'](assert) { + let fooBarInstance, fooBarBazInstance; + + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + fooBarInstance = this; + } + }; + + let FooBarBazComponent = class extends Component { + init() { + super.init(...arguments); + fooBarBazInstance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('foo-bar {{foo-bar-baz}}'), FooBarComponent) + ); + this.owner.register( + 'component:foo-bar-baz', + setComponentTemplate(precompileTemplate('foo-bar-baz'), FooBarBazComponent) + ); + + this.render('{{foo-bar}}'); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + + runTask(() => this.rerender()); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + } + + ['@test it reflects named arguments as properties']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.foo}}'), class extends Component {}) + ); + + this.render('{{foo-bar foo=this.model.bar}}', { + model: { + bar: 'Hola', + }, + }); + + this.assertText('Hola'); + + runTask(() => this.rerender()); + + this.assertText('Hola'); + + runTask(() => this.context.set('model.bar', 'Hello')); + + this.assertText('Hello'); + + runTask(() => this.context.set('model', { bar: 'Hola' })); + + this.assertText('Hola'); + } + + ['@test late bound layouts return the same definition'](assert) { + let templateIds = []; + + // This is testing the scenario where you import a template and + // set it to the layout property: + // + // import Component from '@ember/component'; + // import layout from './template'; + // + // export default Component.extend({ + // layout + // }); + let hello = precompileTemplate('Hello'); + let bye = precompileTemplate('Bye'); + + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + this.layout = this.cond ? hello : bye; + templateIds.push(this.layout.id); + } + }; + + this.owner.register('component:foo-bar', FooBarComponent); + + this.render( + '{{foo-bar cond=true}}{{foo-bar cond=false}}{{foo-bar cond=true}}{{foo-bar cond=false}}' + ); + + let [t1, t2, t3, t4] = templateIds; + assert.equal(t1, t3); + assert.equal(t2, t4); + } + + // Perhaps change this test to `{{this.attrs.someProp.value}}` when removing the deprecation? + ['@test non-block with properties on this.attrs']() { + expectDeprecation(() => { + this.owner.register( + 'component:non-block', + template('In layout - someProp: {{this.attrs.someProp}}', { + component: class extends Component {}, + strictMode: false, + }) + ); + }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs.someProp}} should be updated to {{@someProp}}./); + + this.render('{{non-block someProp=this.prop}}', { + prop: 'something here', + }); + + this.assertText('In layout - someProp: something here'); + + runTask(() => this.rerender()); + + this.assertText('In layout - someProp: something here'); + + runTask(() => this.context.set('prop', 'other thing there')); + + this.assertText('In layout - someProp: other thing there'); + + runTask(() => this.context.set('prop', 'something here')); + + this.assertText('In layout - someProp: something here'); + } + + ['@test non-block with properties overridden in init']() { + let instance; + this.owner.register( + 'component:non-block', + setComponentTemplate( + precompileTemplate('In layout - someProp: {{this.someProp}}'), + class extends Component { + init() { + super.init(...arguments); + instance = this; + this.someProp = 'value set in instance'; + } + } + ) + ); + + this.render('{{non-block someProp=this.prop}}', { + prop: 'something passed when invoked', + }); + + this.assertText('In layout - someProp: value set in instance'); + + runTask(() => this.rerender()); + + this.assertText('In layout - someProp: value set in instance'); + + runTask(() => this.context.set('prop', 'updated something passed when invoked')); + + this.assertText('In layout - someProp: updated something passed when invoked'); + + runTask(() => instance.set('someProp', 'update value set in instance')); + + this.assertText('In layout - someProp: update value set in instance'); + + runTask(() => this.context.set('prop', 'something passed when invoked')); + runTask(() => instance.set('someProp', 'value set in instance')); + + this.assertText('In layout - someProp: value set in instance'); + } + + ['@test rerendering component with attrs from parent'](assert) { + let willUpdateCount = 0; + let didReceiveAttrsCount = 0; + + function expectHooks({ willUpdate, didReceiveAttrs }, callback) { + willUpdateCount = 0; + didReceiveAttrsCount = 0; + + callback(); + + if (willUpdate) { + assert.strictEqual(willUpdateCount, 1, 'The willUpdate hook was fired'); + } else { + assert.strictEqual(willUpdateCount, 0, 'The willUpdate hook was not fired'); + } + + if (didReceiveAttrs) { + assert.strictEqual(didReceiveAttrsCount, 1, 'The didReceiveAttrs hook was fired'); + } else { + assert.strictEqual(didReceiveAttrsCount, 0, 'The didReceiveAttrs hook was not fired'); + } + } + + this.owner.register( + 'component:non-block', + setComponentTemplate( + precompileTemplate('In layout - someProp: {{this.someProp}}'), + class extends Component { + didReceiveAttrs() { + didReceiveAttrsCount++; + } + + willUpdate() { + willUpdateCount++; + } + } + ) + ); + + expectHooks({ willUpdate: false, didReceiveAttrs: true }, () => { + this.render('{{non-block someProp=this.someProp}}', { + someProp: 'wycats', + }); + }); + + this.assertText('In layout - someProp: wycats'); + + // Note: Hooks are not fired in Glimmer for idempotent re-renders + expectHooks({ willUpdate: false, didReceiveAttrs: false }, () => { + runTask(() => this.rerender()); + }); + + this.assertText('In layout - someProp: wycats'); + + expectHooks({ willUpdate: true, didReceiveAttrs: true }, () => { + runTask(() => this.context.set('someProp', 'tomdale')); + }); + + this.assertText('In layout - someProp: tomdale'); + + // Note: Hooks are not fired in Glimmer for idempotent re-renders + expectHooks({ willUpdate: false, didReceiveAttrs: false }, () => { + runTask(() => this.rerender()); + }); + + this.assertText('In layout - someProp: tomdale'); + + expectHooks({ willUpdate: true, didReceiveAttrs: true }, () => { + runTask(() => this.context.set('someProp', 'wycats')); + }); + + this.assertText('In layout - someProp: wycats'); + } + + ['@test setting a value for a computed property then later getting the value for that property works']( + assert + ) { + let componentInstance = null; + + this.owner.register( + 'component:non-block', + setComponentTemplate( + precompileTemplate(''), + class extends Component { + counter = computed({ + set(key, value) { + return value; + }, + }); + + init() { + super.init(...arguments); + componentInstance = this; + } + + @action + myClick() { + let currentCounter = this.get('counter'); + + assert.equal(currentCounter, 0, 'the current `counter` value is correct'); + + let newCounter = currentCounter + 1; + this.set('counter', newCounter); + + assert.equal( + this.get('counter'), + newCounter, + "getting the newly set `counter` property works; it's equal to the value we just set and not `undefined`" + ); + } + } + ) + ); + + this.render(`{{non-block counter=this.counter}}`, { + counter: 0, + }); + + runTask(() => this.$('button').click()); + + assert.equal( + componentInstance.get('counter'), + 1, + '`counter` incremented on click on the component and is not `undefined`' + ); + } + + // Perhaps change this test to `{{this.attrs.foo.value}}` when removing the deprecation? + ['@test this.attrs.foo === @foo === foo']() { + expectDeprecation(() => { + this.owner.register( + 'component:foo-bar', + template( + strip` + Args: {{this.attrs.value}} | {{@value}} | {{this.value}} + {{#each this.attrs.items as |item|}} + {{item}} + {{/each}} + {{#each @items as |item|}} + {{item}} + {{/each}} + {{#each this.items as |item|}} + {{item}} + {{/each}} + `, + { component: class extends Component {}, strictMode: false } + ) + ); + }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs..+}} should be updated to {{@.+}}./); + + this.render('{{foo-bar value=this.model.value items=this.model.items}}', { + model: { + value: 'wat', + items: [1, 2, 3], + }, + }); + + this.assertStableRerender(); + + runTask(() => { + this.context.set('model.value', 'lul'); + this.context.set('model.items', [1]); + }); + + this.assertText(strip`Args: lul | lul | lul111`); + + runTask(() => this.context.set('model', { value: 'wat', items: [1, 2, 3] })); + + this.assertText('Args: wat | wat | wat123123123'); + } + + ['@test non-block with properties on self']() { + this.owner.register( + 'component:non-block', + setComponentTemplate( + precompileTemplate('In layout - someProp: {{this.someProp}}'), + class extends Component {} + ) + ); + + this.render('{{non-block someProp=this.prop}}', { + prop: 'something here', + }); + + this.assertText('In layout - someProp: something here'); + + runTask(() => this.rerender()); + + this.assertText('In layout - someProp: something here'); + + runTask(() => this.context.set('prop', 'something else')); + + this.assertText('In layout - someProp: something else'); + + runTask(() => this.context.set('prop', 'something here')); + + this.assertText('In layout - someProp: something here'); + } + + ['@test block with properties on self']() { + this.owner.register( + 'component:with-block', + setComponentTemplate( + precompileTemplate('In layout - someProp: {{this.someProp}} - {{yield}}'), + class extends Component {} + ) + ); + + this.render( + strip` + {{#with-block someProp=this.prop}} + In template + {{/with-block}}`, + { + prop: 'something here', + } + ); + + this.assertText('In layout - someProp: something here - In template'); + + runTask(() => this.rerender()); + + this.assertText('In layout - someProp: something here - In template'); + + runTask(() => this.context.set('prop', 'something else')); + + this.assertText('In layout - someProp: something else - In template'); + + runTask(() => this.context.set('prop', 'something here')); + + this.assertText('In layout - someProp: something here - In template'); + } + + // Perhaps change this test to `{{this.attrs.someProp.value}}` when removing the deprecation? + ['@test block with properties on this.attrs']() { + expectDeprecation(() => { + this.owner.register( + 'component:with-block', + template('In layout - someProp: {{this.attrs.someProp}} - {{yield}}', { + component: class extends Component {}, + strictMode: false, + }) + ); + }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs.someProp}} should be updated to {{@someProp}}./); + + this.render( + strip` + {{#with-block someProp=this.prop}} + In template + {{/with-block}}`, + { + prop: 'something here', + } + ); + + this.assertText('In layout - someProp: something here - In template'); + + runTask(() => this.rerender()); + + this.assertText('In layout - someProp: something here - In template'); + + runTask(() => this.context.set('prop', 'something else')); + + this.assertText('In layout - someProp: something else - In template'); + + runTask(() => this.context.set('prop', 'something here')); + + this.assertText('In layout - someProp: something here - In template'); + } + + ['@test arbitrary positional parameter conflict with hash parameter is reported']() { + this.owner.register( + 'component:sample-component', + template( + strip` + {{#each this.names as |name|}} + {{name}} + {{/each}} + `, + { + component: class extends Component { + static positionalParams = 'names'; + }, + strictMode: false, + } + ) + ); + + expectAssertion(() => { + this.render(`{{sample-component "Foo" 4 "Bar" names=this.numbers id="args-3"}}`, { + numbers: [1, 2, 3], + }); + }, 'You cannot specify positional parameters and the hash argument `names`.'); + } + + ['@test can use hash parameter instead of arbitrary positional param [GH #12444]']() { + this.owner.register( + 'component:sample-component', + template( + strip` + {{#each this.names as |name|}} + {{name}} + {{/each}} + `, + { + component: class extends Component { + static positionalParams = 'names'; + }, + strictMode: false, + } + ) + ); + + this.render('{{sample-component names=this.things}}', { + things: trackedArray(['Foo', 4, 'Bar']), + }); + + this.assertText('Foo4Bar'); + + runTask(() => this.rerender()); + + this.assertText('Foo4Bar'); + + runTask(() => this.context.get('things').push(5)); + + this.assertText('Foo4Bar5'); + + runTask(() => this.context.get('things').shift()); + + this.assertText('4Bar5'); + + runTask(() => this.context.get('things').splice(0)); + + this.assertText(''); + + runTask(() => this.context.set('things', ['Foo', 4, 'Bar'])); + + this.assertText('Foo4Bar'); + } + + ['@test can use hash parameter instead of positional param'](assert) { + this.owner.register( + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{this.first}} - {{this.second}}'), + class extends Component { + static positionalParams = ['first', 'second']; + } + ) + ); + + // TODO: Fix when id is implemented + this.render(strip` + {{sample-component "one" "two" elementId="two-positional"}} + {{sample-component "one" second="two" elementId="one-positional"}} + {{sample-component first="one" second="two" elementId="no-positional"}}`); + + assert.equal(this.$('#two-positional').text(), 'one - two'); + assert.equal(this.$('#one-positional').text(), 'one - two'); + assert.equal(this.$('#no-positional').text(), 'one - two'); + + runTask(() => this.rerender()); + + assert.equal(this.$('#two-positional').text(), 'one - two'); + assert.equal(this.$('#one-positional').text(), 'one - two'); + assert.equal(this.$('#no-positional').text(), 'one - two'); + } + + ['@test with ariaRole specified']() { + this.owner.register( + 'component:aria-test', + setComponentTemplate(precompileTemplate('Here!'), class extends Component {}) + ); + + this.render('{{aria-test ariaRole=this.role}}', { + role: 'main', + }); + + this.assertComponentElement(this.firstChild, { attrs: { role: 'main' } }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { attrs: { role: 'main' } }); + + runTask(() => this.context.set('role', 'input')); + + this.assertComponentElement(this.firstChild, { + attrs: { role: 'input' }, + }); + + runTask(() => this.context.set('role', 'main')); + + this.assertComponentElement(this.firstChild, { attrs: { role: 'main' } }); + } + + ['@test with ariaRole defined but initially falsey GH#16379']() { + this.owner.register( + 'component:aria-test', + setComponentTemplate(precompileTemplate('Here!'), class extends Component {}) + ); + + this.render('{{aria-test ariaRole=this.role}}', { + role: undefined, + }); + + this.assertComponentElement(this.firstChild, { attrs: {} }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { attrs: {} }); + + runTask(() => this.context.set('role', 'input')); + + this.assertComponentElement(this.firstChild, { + attrs: { role: 'input' }, + }); + + runTask(() => this.context.set('role', undefined)); + + this.assertComponentElement(this.firstChild, { attrs: {} }); + } + + ['@test without ariaRole defined initially']() { + // we are using the ability to lazily add a role as a sign that we are + // doing extra work + let instance; + this.owner.register( + 'component:aria-test', + setComponentTemplate( + precompileTemplate('Here!'), + class extends Component { + init() { + super.init(...arguments); + instance = this; + } + } + ) + ); + + this.render('{{aria-test}}'); + + this.assertComponentElement(this.firstChild, { attrs: {} }); + + runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { attrs: {} }); + + runTask(() => instance.set('ariaRole', 'input')); + + this.assertComponentElement(this.firstChild, { attrs: {} }); + } + + ['@test `template` specified in component is overridden by block']() { + this.owner.register( + 'component:with-template', + setComponentTemplate( + precompileTemplate('[In layout - {{this.name}}] {{yield}}'), + class extends Component { + template = 'Should not be used'; + } + ) + ); + + this.render( + strip` + {{#with-template name="with-block"}} + [In block - {{this.name}}] + {{/with-template}} + {{with-template name="without-block"}}`, + { + name: 'Whoop, whoop!', + } + ); + + this.assertText( + '[In layout - with-block] [In block - Whoop, whoop!][In layout - without-block] ' + ); + + runTask(() => this.rerender()); + + this.assertText( + '[In layout - with-block] [In block - Whoop, whoop!][In layout - without-block] ' + ); + + runTask(() => this.context.set('name', 'Ole, ole')); + + this.assertText('[In layout - with-block] [In block - Ole, ole][In layout - without-block] '); + + runTask(() => this.context.set('name', 'Whoop, whoop!')); + + this.assertText( + '[In layout - with-block] [In block - Whoop, whoop!][In layout - without-block] ' + ); + } + + ['@test if a value is passed as a non-positional parameter, it raises an assertion']() { + this.owner.register( + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{this.name}}'), + class extends Component { + static positionalParams = ['name']; + } + ) + ); + + expectAssertion(() => { + this.render('{{sample-component this.notMyName name=this.myName}}', { + myName: 'Quint', + notMyName: 'Sergio', + }); + }, 'You cannot specify both a positional param (at position 0) and the hash argument `name`.'); + } + + ['@test component in template of a yielding component should have the proper parentView']( + assert + ) { + let outer, innerTemplate, innerLayout; + + this.owner.register( + 'component:x-outer', + setComponentTemplate( + precompileTemplate('{{x-inner-in-layout}}{{yield}}'), + class extends Component { + init() { + super.init(...arguments); + outer = this; + } + } + ) + ); + + this.owner.register( + 'component:x-inner-in-template', + class extends Component { + init() { + super.init(...arguments); + innerTemplate = this; + } + } + ); + + this.owner.register( + 'component:x-inner-in-layout', + class extends Component { + init() { + super.init(...arguments); + innerLayout = this; + } + } + ); + + this.render('{{#x-outer}}{{x-inner-in-template}}{{/x-outer}}'); + + assert.equal( + innerTemplate.parentView, + outer, + 'receives the wrapping component as its parentView in template blocks' + ); + assert.equal( + innerLayout.parentView, + outer, + 'receives the wrapping component as its parentView in layout' + ); + assert.equal( + outer.parentView, + this.context, + 'x-outer receives the ambient scope as its parentView' + ); + + runTask(() => this.rerender()); + + assert.equal( + innerTemplate.parentView, + outer, + 'receives the wrapping component as its parentView in template blocks' + ); + assert.equal( + innerLayout.parentView, + outer, + 'receives the wrapping component as its parentView in layout' + ); + assert.equal( + outer.parentView, + this.context, + 'x-outer receives the ambient scope as its parentView' + ); + } + + ['@test newly-added sub-components get correct parentView'](assert) { + let outer, inner; + + this.owner.register( + 'component:x-outer', + class extends Component { + init() { + super.init(...arguments); + outer = this; + } + } + ); + + this.owner.register( + 'component:x-inner', + class extends Component { + init() { + super.init(...arguments); + inner = this; + } + } + ); + + this.render( + strip` + {{#x-outer}} + {{#if this.showInner}} + {{x-inner}} + {{/if}} + {{/x-outer}}`, + { + showInner: false, + } + ); + + assert.equal( + outer.parentView, + this.context, + 'x-outer receives the ambient scope as its parentView' + ); + + runTask(() => this.rerender()); + + assert.equal( + outer.parentView, + this.context, + 'x-outer receives the ambient scope as its parentView (after rerender)' + ); + + runTask(() => this.context.set('showInner', true)); + + assert.equal( + outer.parentView, + this.context, + 'x-outer receives the ambient scope as its parentView' + ); + assert.equal( + inner.parentView, + outer, + 'receives the wrapping component as its parentView in template blocks' + ); + + runTask(() => this.context.set('showInner', false)); + + assert.equal( + outer.parentView, + this.context, + 'x-outer receives the ambient scope as its parentView' + ); + } + + ['@test specifying classNames results in correct class'](assert) { + this.owner.register( + 'component:some-clicky-thing', + class extends Component { + tagName = 'button'; + classNames = ['foo', 'bar']; + } + ); + + this.render(strip` + {{#some-clicky-thing classNames="baz"}} + Click Me + {{/some-clicky-thing}}`); + + // TODO: ember-view is no longer viewable in the classNames array. Bug or + // feature? + let expectedClassNames = ['ember-view', 'foo', 'bar', 'baz']; + + assert.ok( + this.$('button').is('.foo.bar.baz.ember-view'), + `the element has the correct classes: ${this.$('button').attr('class')}` + ); + // `ember-view` is no longer in classNames. + // assert.deepEqual(clickyThing.get('classNames'), expectedClassNames, 'classNames are properly combined'); + this.assertComponentElement(this.firstChild, { + tagName: 'button', + attrs: { class: classes(expectedClassNames.join(' ')) }, + }); + + runTask(() => this.rerender()); + + assert.ok( + this.$('button').is('.foo.bar.baz.ember-view'), + `the element has the correct classes: ${this.$('button').attr('class')} (rerender)` + ); + // `ember-view` is no longer in classNames. + // assert.deepEqual(clickyThing.get('classNames'), expectedClassNames, 'classNames are properly combined (rerender)'); + this.assertComponentElement(this.firstChild, { + tagName: 'button', + attrs: { class: classes(expectedClassNames.join(' ')) }, + }); + } + + ['@test specifying custom concatenatedProperties avoids clobbering']() { + this.owner.register( + 'component:some-clicky-thing', + template( + strip` + {{#each this.blahzz as |p|}} + {{p}} + {{/each}} + - {{yield}} + `, + { + component: class extends Component { + concatenatedProperties = ['blahzz']; + blahzz = ['blark', 'pory']; + }, + strictMode: false, + } + ) + ); + + this.render(strip` + {{#some-clicky-thing blahzz="baz"}} + Click Me + {{/some-clicky-thing}}`); + + this.assertText('blarkporybaz- Click Me'); + + runTask(() => this.rerender()); + + this.assertText('blarkporybaz- Click Me'); + } + + ['@test a two way binding flows upstream when consumed in the template']() { + let component; + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + component = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.bar}}'), FooBarComponent) + ); + + this.render('{{this.localBar}} - {{foo-bar bar=this.localBar}}', { + localBar: 'initial value', + }); + + this.assertText('initial value - initial value'); + + runTask(() => this.rerender()); + + this.assertText('initial value - initial value'); + + runTask(() => { + component.set('bar', 'updated value'); + }); + + this.assertText('updated value - updated value'); + + runTask(() => { + component.set('bar', undefined); + }); + + this.assertText(' - '); + + runTask(() => { + this.component.set('localBar', 'initial value'); + }); + + this.assertText('initial value - initial value'); + } + + ['@test a two way binding flows upstream through a CP when consumed in the template']() { + let component; + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + component = this; + } + + @computed + get bar() { + return this._bar; + } + set bar(value) { + this._bar = value; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.bar}}'), FooBarComponent) + ); + + this.render('{{this.localBar}} - {{foo-bar bar=this.localBar}}', { + localBar: 'initial value', + }); + + this.assertText('initial value - initial value'); + + runTask(() => this.rerender()); + + this.assertText('initial value - initial value'); + + runTask(() => { + component.set('bar', 'updated value'); + }); + + this.assertText('updated value - updated value'); + + runTask(() => { + this.component.set('localBar', 'initial value'); + }); + + this.assertText('initial value - initial value'); + } + + ['@test a two way binding flows upstream through a CP without template consumption']() { + let component; + let FooBarComponent = class extends Component { + init() { + super.init(...arguments); + component = this; + } + + @computed + get bar() { + return this._bar; + } + + set bar(value) { + this._bar = value; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate(''), FooBarComponent) + ); + + this.render('{{this.localBar}}{{foo-bar bar=this.localBar}}', { + localBar: 'initial value', + }); + + this.assertText('initial value'); + + runTask(() => this.rerender()); + + this.assertText('initial value'); + + runTask(() => { + component.set('bar', 'updated value'); + }); + + this.assertText('updated value'); + + runTask(() => { + this.component.set('localBar', 'initial value'); + }); + + this.assertText('initial value'); + } + + ['@test GH#18417 - a two way binding flows upstream to a parent component through a CP']() { + let parent, child; + let ParentComponent = class extends Component { + init() { + super.init(...arguments); + parent = this; + } + + @tracked string = 'Hello|World'; + }; + + this.owner.register( + 'component:parent', + setComponentTemplate( + precompileTemplate( + '{{child value=this.string}}Parent String={{this.string}}' + ), + ParentComponent + ) + ); + + let ChildComponent = class extends Component { + init() { + super.init(...arguments); + child = this; + } + + a = null; // computed based on passed in value of `string` + b = null; // computed based on passed in value of `string` + + @computed('a', 'b') + get value() { + return this.a + '|' + this.b; + } + + set value(value) { + let vals = value.split('|'); + this.set('a', vals[0]); + this.set('b', vals[1]); + } + }; + + this.owner.register( + 'component:child', + setComponentTemplate(precompileTemplate('{{this.value}}'), ChildComponent) + ); + + this.render('{{parent}}'); + + this.assert.equal(parent.string, 'Hello|World', 'precond - parent value'); + this.assert.equal( + this.element.querySelector('[data-test-parent-value]').textContent.trim(), + 'Hello|World', + 'precond - parent rendered value' + ); + + runTask(() => { + child.set('a', 'Foo'); + }); + + this.assert.equal(parent.string, 'Foo|World', 'parent value updated'); + + this.assert.equal( + this.element.querySelector('[data-test-parent-value]').textContent.trim(), + 'Foo|World', + 'parent updated value rendered' + ); + + runTask(() => { + child.set('a', 'Hello'); + }); + + this.assert.equal(parent.string, 'Hello|World', 'parent value reset'); + this.assert.equal( + this.element.querySelector('[data-test-parent-value]').textContent.trim(), + 'Hello|World', + 'parent template reset' + ); + } + + ['@test injecting an unknown service raises an exception']() { + this.owner.register( + 'component:foo-bar', + class extends Component { + @service + missingService; + } + ); + + expectAssertion(() => { + this.render('{{foo-bar}}'); + }, "Attempting to inject an unknown injection: 'service:missingService'"); + } + + ['@test throws if `super.init` is not called from `init`']() { + this.owner.register( + 'component:foo-bar', + class extends Component { + init() {} + } + ); + + expectAssertion(() => { + this.render('{{foo-bar}}'); + }, /You must call `super.init\(...arguments\);` or `this._super\(...arguments\)` when overriding `init` on a framework object. Please update .*/); + } + + ['@test it can use readDOMAttr to read input value']() { + let component; + let assertElement = (expectedValue) => { + // value is a property, not an attribute + this.assertHTML(``); + this.assert.equal(this.firstChild.value, expectedValue, 'value property is correct'); + this.assert.equal( + get(component, 'value'), + expectedValue, + 'component.get("value") is correct' + ); + }; + + this.owner.register( + 'component:one-way-input', + class extends Component { + tagName = 'input'; + attributeBindings = ['value']; + + init() { + super.init(...arguments); + component = this; + } + + change() { + let value = this.readDOMAttr('value'); + this.set('value', value); + } + } + ); + + this.render('{{one-way-input value=this.value}}', { + value: 'foo', + }); + + assertElement('foo'); + + this.assertStableRerender(); + + runTask(() => { + this.firstChild.value = 'bar'; + this.$('input').trigger('change'); + }); + + assertElement('bar'); + + runTask(() => { + this.firstChild.value = 'foo'; + this.$('input').trigger('change'); + }); + + assertElement('foo'); + + runTask(() => { + set(component, 'value', 'bar'); + }); + + assertElement('bar'); + + runTask(() => { + this.firstChild.value = 'foo'; + this.$('input').trigger('change'); + }); + + assertElement('foo'); + } + + ['@test child triggers revalidate during parent destruction (GH#13846)']() { + this.owner.register( + 'component:x-select', + setComponentTemplate( + precompileTemplate('{{yield this}}'), + class extends Component { + tagName = 'select'; + + init() { + super.init(...arguments); + this.options = []; + this.value = null; + } + + updateValue() { + let newValue = this.options.at(-1)?.value; + + this.set('value', newValue); + } + + registerOption(option) { + this.options.push(option); + } + + unregisterOption(option) { + this.options.splice(this.options.indexOf(option), 1); + + this.updateValue(); + } + } + ) + ); + + this.owner.register( + 'component:x-option', + class extends Component { + tagName = 'option'; + attributeBindings = ['selected']; + + didInsertElement() { + super.didInsertElement(...arguments); + + this.get('select').registerOption(this); + } + + @computed('select.value') + get selected() { + return this.get('value') === this.get('select.value'); + } + + willDestroyElement() { + super.willDestroyElement(...arguments); + this.get('select').unregisterOption(this); + } + } + ); + + this.render(strip` + {{#x-select value=this.value as |select|}} + {{#x-option value="1" select=select}}1{{/x-option}} + {{#x-option value="2" select=select}}2{{/x-option}} + {{/x-select}} + `); + + this.teardown(); + + this.assert.ok(true, 'no errors during teardown'); + } + + ['@test setting a property in willDestroyElement does not assert (GH#14273)'](assert) { + assert.expect(2); + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate(`{{#if this.showFoo}}things{{/if}}`), + class extends Component { + init() { + super.init(...arguments); + this.showFoo = true; + } + + willDestroyElement() { + this.set('showFoo', false); + assert.ok(true, 'willDestroyElement was fired'); + super.willDestroyElement(...arguments); + } + } + ) + ); + + this.render(`{{foo-bar}}`); + + this.assertText('things'); + } + + async ['@test didReceiveAttrs fires after .init() but before observers become active'](assert) { + let barCopyDidChangeCount = 0; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.bar}}-{{this.barCopy}}'), + Component.extend({ + init() { + this._super(...arguments); + this.didInit = true; + }, + + didReceiveAttrs() { + assert.ok(this.didInit, 'expected init to have run before didReceiveAttrs'); + this.set('barCopy', this.attrs.bar.value + 1); + }, + + barCopyDidChange: observer('barCopy', () => { + barCopyDidChangeCount++; + }), + }) + ) + ); + + await this.render(`{{foo-bar bar=this.bar}}`, { bar: 3 }); + + this.assertText('3-4'); + + assert.strictEqual(barCopyDidChangeCount, 1, 'expected observer firing for: barCopy'); + + set(this.context, 'bar', 7); + + await runLoopSettled(); + + this.assertText('7-8'); + + assert.strictEqual(barCopyDidChangeCount, 2, 'expected observer firing for: barCopy'); + } + + ['@test overriding didReceiveAttrs does not trigger deprecation'](assert) { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.foo}}-{{this.fooCopy}}-{{this.bar}}-{{this.barCopy}}'), + class extends Component { + didReceiveAttrs() { + assert.equal(1, this.get('foo'), 'expected attrs to have correct value'); + } + } + ) + ); + + this.render(`{{foo-bar foo=this.foo bar=this.bar}}`, { foo: 1, bar: 3 }); + } + + ['@test overriding didUpdateAttrs does not trigger deprecation'](assert) { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.foo}}-{{this.fooCopy}}-{{this.bar}}-{{this.barCopy}}'), + class extends Component { + didUpdateAttrs() { + assert.equal(5, this.get('foo'), 'expected newAttrs to have new value'); + } + } + ) + ); + + this.render(`{{foo-bar foo=this.foo bar=this.bar}}`, { foo: 1, bar: 3 }); + + runTask(() => set(this.context, 'foo', 5)); + } + + [`${testUnless(DEPRECATIONS.DEPRECATE_EVENTED.isRemoved)} @test triggering an event only attempts to invoke an identically named method, if it actually is a function (GH#15228)`]( + assert + ) { + assert.expect(5); + + let payload = ['arbitrary', 'event', 'data']; + + expectDeprecation( + () => { + this.owner.register( + 'component:evented-component', + Component.extend({ + someTruthyProperty: true, + + init() { + this._super(...arguments); + expectDeprecation( + () => { + this.trigger('someMethod', ...payload); + this.trigger('someTruthyProperty', ...payload); + }, + /Evented#trigger` is deprecated/, + DEPRECATIONS.DEPRECATE_EVENTED.isEnabled + ); + }, + + someMethod(...data) { + assert.deepEqual( + data, + payload, + 'the method `someMethod` should be called, when `someMethod` is triggered' + ); + }, + + listenerForSomeMethod: on('someMethod', function (...data) { + assert.deepEqual( + data, + payload, + 'the listener `listenerForSomeMethod` should be called, when `someMethod` is triggered' + ); + }), + + listenerForSomeTruthyProperty: on('someTruthyProperty', function (...data) { + assert.deepEqual( + data, + payload, + 'the listener `listenerForSomeTruthyProperty` should be called, when `someTruthyProperty` is triggered' + ); + }), + }) + ); + }, + /`on\(\)` event decorator is deprecated/, + DEPRECATIONS.DEPRECATE_EVENTED.isEnabled + ); + + this.render(`{{evented-component}}`); + } + + ['@test unimplimented positionalParams do not cause an error GH#14416']() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + ); + + this.render('{{foo-bar this.wat}}'); + this.assertText('hello'); + } + + ['@test using attrs for positional params is asserted against']() { + let MyComponent = class extends Component {}; + + expectAssertion(() => { + this.owner.register( + 'component:foo-bar', + template( + 'MyVar1: {{attrs.myVar}} {{this.myVar}} MyVar2: {{this.myVar2}} {{attrs.myVar2}}', + { + component: MyComponent.reopenClass({ positionalParams: ['myVar'] }), + strictMode: false, + } + ) + ); + }, 'Using {{attrs}} to reference named arguments is not supported. {{attrs.myVar}} should be updated to {{@myVar}}. (L1:C10) '); + } + + // Perhaps change this test to `{{this.attrs.myVar.value}}` when removing the deprecation? + ['@test using this.attrs for positional params']() { + let MyComponent = class extends Component {}; + + expectDeprecation(() => { + this.owner.register( + 'component:foo-bar', + template( + 'MyVar1: {{this.attrs.myVar}} {{this.myVar}} MyVar2: {{this.myVar2}} {{this.attrs.myVar2}}', + { + component: MyComponent.reopenClass({ positionalParams: ['myVar'] }), + strictMode: false, + } + ) + ); + }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs.myVar2?}} should be updated to {{@myVar2?}}./); + + this.render('{{foo-bar 1 myVar2=2}}'); + + this.assertText('MyVar1: 1 1 MyVar2: 2 2'); + } + + ['@test using named arguments for positional params']() { + let MyComponent = class extends Component {}; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate( + 'MyVar1: {{@myVar}} {{this.myVar}} MyVar2: {{this.myVar2}} {{@myVar2}}' + ), + MyComponent.reopenClass({ + positionalParams: ['myVar'], + }) + ) + ); + + this.render('{{foo-bar 1 myVar2=2}}'); + + this.assertText('MyVar1: 1 1 MyVar2: 2 2'); + } + + ['@test has attrs by didReceiveAttrs with native classes'](assert) { + class FooBarComponent extends Component { + constructor(injections) { + super(injections); + // analogous to class field defaults + this.foo = 'bar'; + } + + didReceiveAttrs() { + assert.equal(this.foo, 'bar', 'received default attrs correctly'); + } + } + + this.owner.register('component:foo-bar', FooBarComponent); + + this.render('{{foo-bar}}'); + } + + '@test lifecycle hooks are not tracked'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.baz}}'), + class extends Component { + @tracked foo; + + willInsertElement() { + this.foo; + this.foo = 123; + } + + willRender() { + this.foo; + this.foo = 123; + } + + didRender() { + this.foo; + this.foo = 123; + } + + didReceiveAttrs() { + this.foo; + this.foo = 123; + } + + didUpdate() { + this.foo; + this.foo = 123; + } + + didUpdateAttrs() { + this.foo; + this.foo = 123; + } + + didInsertElement() { + this.foo; + this.foo = 123; + } + + willClearRender() { + this.foo; + this.foo = 123; + } + + willDestroyElement() { + this.foo; + this.foo = 123; + } + + didDestroyElement() { + this.foo; + this.foo = 123; + } + } + ) + ); + + this.render('{{#if this.cond}}{{foo-bar baz=this.value}}{{/if}}', { + cond: true, + value: 'hello', + }); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + + runTask(() => set(this.context, 'value', 'world')); + + this.assertComponentElement(this.firstChild, { content: 'world' }); + + runTask(() => set(this.context, 'cond', false)); + } + + '@test tracked property mutation in init does not error'() { + // TODO: this should issue a deprecation, but since the curly manager + // uses an untracked frame for construction we don't (yet) + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate(`{{this.itemCount}}`), + class extends Component { + @tracked itemCount = 0; + + init() { + super.init(...arguments); + + // first read the tracked property + let { itemCount } = this; + + // then attempt to update the tracked property + this.itemCount = itemCount + 1; + } + } + ) + ); + + this.render(''); + + this.assertComponentElement(this.firstChild, { content: '1' }); + } + + '@test a block yielded by a tagless component sees the caller scope, the layout sees its own'() { + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate( + '[Layout: {{this.zomg}}][Layout: {{this.lol}}][Layout: {{this.foo}}]{{yield}}' + ), + class extends Component { + tagName = ''; + } + ) + ); + + this.render( + strip` +
+ [Outside: {{this.zomg}}] + {{#let this.zomg as |lol|}} + [Inside: {{this.zomg}}] + [Inside: {{lol}}] + {{#foo-bar foo=this.zomg}} + [Block: {{this.zomg}}] + [Block: {{lol}}] + {{/foo-bar}} + {{/let}} +
`, + { zomg: 'zomg' } + ); + + this.assertText( + '[Outside: zomg][Inside: zomg][Inside: zomg][Layout: ][Layout: ][Layout: zomg][Block: zomg][Block: zomg]' + ); + + this.assertStableRerender(); + } + + '@test a component with a slashed name can be invoked with curlies'() { + this.owner.register( + 'component:fizz-bar/baz-bar', + setComponentTemplate(precompileTemplate('{{this.hey}}'), class extends Component {}) + ); + + this.render('{{fizz-bar/baz-bar hey="hello"}}'); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'hello' }); + + this.assertStableRerender(); + } + + '@test static arbitrary number of positional parameters'() { + this.owner.register( + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{#each this.names key="@index" as |name|}}{{name}}{{/each}}'), + class extends Component { + static positionalParams = 'names'; + } + ) + ); + + this.render(strip` + {{sample-component "Foo" 4 "Bar"}} + {{sample-component "Foo" 4 "Bar" 5 "Baz"}} + `); + + this.assertComponentElement(this.nthChild(0), { tagName: 'div', content: 'Foo4Bar' }); + this.assertComponentElement(this.nthChild(1), { tagName: 'div', content: 'Foo4Bar5Baz' }); + } + + '@test dynamic arbitrary number of positional parameters'() { + this.owner.register( + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{#each this.n key="@index" as |name|}}{{name}}{{/each}}'), + class extends Component { + static positionalParams = 'n'; + } + ) + ); + + this.render('{{sample-component this.user1 this.user2}}', { user1: 'Foo', user2: 4 }); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'Foo4' }); + + runTask(() => { + set(this.context, 'user1', 'Bar'); + set(this.context, 'user2', '5'); + }); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'Bar5' }); + + runTask(() => set(this.context, 'user2', '6')); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'Bar6' }); + } + + '@test the {{component}} helper works with positional params'() { + this.owner.register( + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{this.name}}{{this.age}}'), + class extends Component { + static positionalParams = ['name', 'age']; + } + ) + ); + + this.render('{{component "sample-component" this.myName this.myAge}}', { + myName: 'Quint', + myAge: 4, + }); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'Quint4' }); + + runTask(() => { + set(this.context, 'myName', 'Edward'); + set(this.context, 'myAge', '5'); + }); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'Edward5' }); + + runTask(() => { + set(this.context, 'myName', 'Quint'); + set(this.context, 'myAge', '4'); + }); + + this.assertComponentElement(this.firstChild, { tagName: 'div', content: 'Quint4' }); + } + + '@test every component instance gets a unique element id'(assert) { + this.owner.register( + 'component:x-curly', + setComponentTemplate(precompileTemplate(''), class extends Component {}) + ); + + this.render(strip` + {{x-curly}} + {{x-curly}} + {{x-curly}} + `); + + let ids = [0, 1, 2].map((i) => { + let element = this.nthChild(i); + assert.ok(/^ember\d+$/.test(element.id), `element ${i} has a generated id`); + return element.id; + }); + + assert.strictEqual(new Set(ids).size, 3, 'the components each have a unique id'); + } + + '@test a classic component removed by an `if` is destroyed once'(assert) { + let willDestroyElement = 0; + let destroyed = 0; + + this.owner.register( + 'component:destroy-me', + setComponentTemplate( + precompileTemplate('destroy me!'), + class extends Component { + willDestroyElement() { + super.willDestroyElement(...arguments); + willDestroyElement++; + } + + destroy() { + super.destroy(...arguments); + destroyed++; + } + } + ) + ); + + this.render('{{#if this.cond}}{{destroy-me}}{{/if}}', { cond: true }); + + assert.strictEqual(willDestroyElement, 0, 'willDestroyElement is not called initially'); + assert.strictEqual(destroyed, 0, 'destroy is not called initially'); + + runTask(() => set(this.context, 'cond', false)); + + assert.strictEqual(willDestroyElement, 1, 'willDestroyElement is called exactly once'); + assert.strictEqual(destroyed, 1, 'destroy is called exactly once'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/dynamic-components-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/dynamic-components-test.js new file mode 100644 index 00000000000..1e4bfe85f42 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/dynamic-components-test.js @@ -0,0 +1,84 @@ +import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; +import { Component as EmberComponent } from '../../../utils/helpers'; + +moduleFor( + 'Components test: dynamic components (classic component)', + class extends RenderingTestCase { + ['@test it has an element']() { + let instance; + + let FooBarComponent = class extends EmberComponent { + init() { + super.init(); + instance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{component "foo-bar"}}'); + + let element1 = instance.element; + + this.assertComponentElement(element1, { content: 'hello' }); + + runTask(() => this.rerender()); + + let element2 = instance.element; + + this.assertComponentElement(element2, { content: 'hello' }); + + this.assertSameNode(element2, element1); + } + + ['@test it has the right parentView and childViews'](assert) { + let fooBarInstance, fooBarBazInstance; + + let FooBarComponent = class extends EmberComponent { + init() { + super.init(); + fooBarInstance = this; + } + }; + + let FooBarBazComponent = class extends EmberComponent { + init() { + super.init(); + fooBarBazInstance = this; + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('foo-bar {{foo-bar-baz}}'), FooBarComponent) + ); + this.owner.register( + 'component:foo-bar-baz', + setComponentTemplate(precompileTemplate('foo-bar-baz'), FooBarBazComponent) + ); + + this.render('{{component "foo-bar"}}'); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + + runTask(() => this.rerender()); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/each-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/each-test.js new file mode 100644 index 00000000000..305407051a1 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/each-test.js @@ -0,0 +1,78 @@ +import { moduleFor, RenderingTestCase, strip, runTask } from 'internal-test-helpers'; + +import { set } from '@ember/object'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +import { Component as EmberComponent } from '../../../utils/helpers'; + +moduleFor( + 'Syntax test: {{#each}} with native arrays (classic component)', + class extends RenderingTestCase { + makeList(items) { + this.list = items; + } + + replaceList(list) { + runTask(() => set(this.context, 'list', list)); + } + + objectAt(idx) { + return this.list[idx]; + } + + render(template, context = {}) { + context.list = this.list; + + return super.render(template, context); + } + + [`@test updating and setting within #each`]() { + this.makeList([{ value: 1 }, { value: 2 }, { value: 3 }]); + + let FooBarComponent = class extends EmberComponent { + init() { + super.init(...arguments); + this.isEven = true; + this.tagName = 'li'; + } + + _isEven() { + this.set('isEven', this.get('item.value') % 2 === 0); + } + + didUpdate() { + this._isEven(); + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{#if this.isEven}}{{this.item.value}}{{/if}}'), + FooBarComponent + ) + ); + + this.render(strip` + {{#each this.list as |item|}} +
  • Prev
  • + {{foo-bar item=item}} +
  • Next
  • + {{/each}} + `); + + this.assertText('Prev1NextPrev2NextPrev3Next'); + + this.assertStableRerender(); + + runTask(() => set(this.objectAt(0), 'value', 3)); + + this.assertText('PrevNextPrev2NextPrev3Next'); + + this.replaceList([{ value: 1 }, { value: 2 }, { value: 3 }]); + + this.assertText('Prev1NextPrev2NextPrev3Next'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/error-handling-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/error-handling-test.js new file mode 100644 index 00000000000..4c0ebfc319e --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/error-handling-test.js @@ -0,0 +1,81 @@ +import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; +import { set } from '@ember/object'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; +import { Component as EmberComponent } from '../../../utils/helpers'; + +moduleFor( + 'Errors thrown during render (classic component)', + class extends RenderingTestCase { + ['@test it can recover resets the transaction when an error is thrown during didInsertElement']( + assert + ) { + let shouldThrow = true; + let FooBarComponent = class extends EmberComponent { + didInsertElement() { + super.didInsertElement(...arguments); + if (shouldThrow) { + throw new Error('silly mistake!'); + } + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + assert.throws(() => { + this.render('{{#if this.switch}}{{#foo-bar}}{{foo-bar}}{{/foo-bar}}{{/if}}', { + switch: true, + }); + }, /silly mistake/); + + assert.equal( + this.renderer._inRenderTransaction, + false, + 'should not be in a transaction even though an error was thrown' + ); + + this.assertText('hello'); + + runTask(() => set(this.context, 'switch', false)); + + this.assertText(''); + } + + ['@test it can recover resets the transaction when an error is thrown during destroy'](assert) { + let shouldThrow = true; + let FooBarComponent = class extends EmberComponent { + destroy() { + super.destroy(...arguments); + if (shouldThrow) { + throw new Error('silly mistake!'); + } + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + ); + + this.render('{{#if this.switch}}{{#foo-bar}}{{foo-bar}}{{/foo-bar}}{{/if}}', { + switch: true, + }); + + this.assertText('hello'); + + assert.throws(() => { + runTask(() => set(this.context, 'switch', false)); + }, /silly mistake/); + + this.assertText(''); + + shouldThrow = false; + runTask(() => set(this.context, 'switch', true)); + + this.assertText('hello'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/event-dispatcher-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/event-dispatcher-test.js new file mode 100644 index 00000000000..99b63c7e8f5 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/event-dispatcher-test.js @@ -0,0 +1,422 @@ +import { + expectDeprecation, + moduleFor, + RenderingTestCase, + runTask, + testUnless, +} from 'internal-test-helpers'; +import { DEPRECATIONS } from '../../../../../deprecations'; +import { Component as EmberComponent } from '../../../utils/helpers'; +import { _getCurrentRunLoop } from '@ember/runloop'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +function fireNativeWithDataTransfer(node, type, dataTransfer) { + let event = new Event(type, { bubbles: true, cancelable: true }); + event.dataTransfer = dataTransfer; + node.dispatchEvent(event); +} + +function triggerEvent(node, event) { + switch (event) { + case 'focusin': + return node.focus(); + case 'focusout': + return node.blur(); + default: + return node.trigger(event); + } +} + +const SUPPORTED_EMBER_EVENTS = { + touchstart: 'touchStart', + touchmove: 'touchMove', + touchend: 'touchEnd', + touchcancel: 'touchCancel', + keydown: 'keyDown', + keyup: 'keyUp', + keypress: 'keyPress', + mousedown: 'mouseDown', + mouseup: 'mouseUp', + contextmenu: 'contextMenu', + click: 'click', + dblclick: 'doubleClick', + focusin: 'focusIn', + focusout: 'focusOut', + submit: 'submit', + input: 'input', + change: 'change', + dragstart: 'dragStart', + drag: 'drag', + dragenter: 'dragEnter', + dragleave: 'dragLeave', + dragover: 'dragOver', + drop: 'drop', + dragend: 'dragEnd', +}; + +moduleFor( + 'EventDispatcher (classic component)', + class extends RenderingTestCase { + ['@test event handler methods are called when event is triggered'](assert) { + let receivedEvent; + let browserEvent; + + this.owner.register( + 'component:x-button', + class extends EmberComponent { + tagName = 'button'; + + touchMove(event) { + receivedEvent = event; + } + touchStart(event) { + receivedEvent = event; + } + touchEnd(event) { + receivedEvent = event; + } + touchCancel(event) { + receivedEvent = event; + } + keyDown(event) { + receivedEvent = event; + } + keyUp(event) { + receivedEvent = event; + } + keyPress(event) { + receivedEvent = event; + } + mouseDown(event) { + receivedEvent = event; + } + mouseUp(event) { + receivedEvent = event; + } + contextMenu(event) { + receivedEvent = event; + } + click(event) { + receivedEvent = event; + } + doubleClick(event) { + receivedEvent = event; + } + focusIn(event) { + receivedEvent = event; + } + focusOut(event) { + receivedEvent = event; + } + submit(event) { + receivedEvent = event; + } + input(event) { + receivedEvent = event; + } + change(event) { + receivedEvent = event; + } + dragStart(event) { + receivedEvent = event; + } + drag(event) { + receivedEvent = event; + } + dragEnter(event) { + receivedEvent = event; + } + dragLeave(event) { + receivedEvent = event; + } + dragOver(event) { + receivedEvent = event; + } + drop(event) { + receivedEvent = event; + } + dragEnd(event) { + receivedEvent = event; + } + } + ); + + this.render(`{{x-button}}`); + + let elementNode = this.$('button'); + let element = elementNode[0]; + + for (browserEvent in SUPPORTED_EMBER_EVENTS) { + receivedEvent = null; + runTask(() => triggerEvent(elementNode, browserEvent)); + assert.ok(receivedEvent, `${browserEvent} event was triggered`); + assert.strictEqual(receivedEvent.target, element); + } + } + + [`${testUnless(DEPRECATIONS.DEPRECATE_EVENTED.isRemoved)} @test event listeners are called when event is triggered`]( + assert + ) { + let receivedEvent; + let browserEvent; + + this.owner.register( + 'component:x-button', + class extends EmberComponent { + tagName = 'button'; + init() { + super.init(); + Object.keys(SUPPORTED_EMBER_EVENTS).forEach((browserEvent) => { + expectDeprecation( + () => { + this.on(SUPPORTED_EMBER_EVENTS[browserEvent], (event) => (receivedEvent = event)); + }, + /Evented#on` is deprecated/, + DEPRECATIONS.DEPRECATE_EVENTED.isEnabled + ); + }); + } + } + ); + + this.render(`{{x-button}}`); + + let elementNode = this.$('button'); + let element = elementNode[0]; + + for (browserEvent in SUPPORTED_EMBER_EVENTS) { + receivedEvent = null; + runTask(() => triggerEvent(elementNode, browserEvent)); + assert.ok(receivedEvent, `${browserEvent} event was triggered`); + assert.strictEqual(receivedEvent.target, element); + } + } + + ['@test events bubble view hierarchy for form elements'](assert) { + let receivedEvent; + + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(``), + class extends EmberComponent { + change(event) { + receivedEvent = event; + } + } + ) + ); + + this.render(`{{x-foo}}`); + + runTask(() => this.$('#is-done').trigger('change')); + assert.ok(receivedEvent, 'change event was triggered'); + assert.strictEqual(receivedEvent.target, this.$('#is-done')[0]); + } + + ['@test events bubble to parent view'](assert) { + let receivedEvent; + + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(`{{yield}}`), + class extends EmberComponent { + change(event) { + receivedEvent = event; + } + } + ) + ); + + this.owner.register( + 'component:x-bar', + setComponentTemplate( + precompileTemplate(``), + class extends EmberComponent { + change() {} + } + ) + ); + + this.render(`{{#x-foo}}{{x-bar}}{{/x-foo}}`); + + runTask(() => this.$('#is-done').trigger('change')); + assert.ok(receivedEvent, 'change event was triggered'); + assert.strictEqual(receivedEvent.target, this.$('#is-done')[0]); + } + + ['@test events bubbling up can be prevented by returning false'](assert) { + let hasReceivedEvent; + + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(`{{yield}}`), + class extends EmberComponent { + change() { + hasReceivedEvent = true; + } + } + ) + ); + + this.owner.register( + 'component:x-bar', + setComponentTemplate( + precompileTemplate(``), + class extends EmberComponent { + change() { + return false; + } + } + ) + ); + + this.render(`{{#x-foo}}{{x-bar}}{{/x-foo}}`); + + runTask(() => this.$('#is-done').trigger('change')); + assert.notOk(hasReceivedEvent, 'change event has not been received'); + } + + ['@test events bubbling up can be prevented by calling stopPropagation()'](assert) { + let hasReceivedEvent; + + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(`{{yield}}`), + class extends EmberComponent { + change() { + hasReceivedEvent = true; + } + } + ) + ); + + this.owner.register( + 'component:x-bar', + setComponentTemplate( + precompileTemplate(``), + class extends EmberComponent { + change(e) { + e.stopPropagation(); + } + } + ) + ); + + this.render(`{{#x-foo}}{{x-bar}}{{/x-foo}}`); + + runTask(() => this.$('#is-done').trigger('change')); + assert.notOk(hasReceivedEvent, 'change event has not been received'); + } + + ['@test event handlers are wrapped in a run loop'](assert) { + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(``), + class extends EmberComponent { + change() { + assert.ok(_getCurrentRunLoop(), 'a run loop should have started'); + } + } + ) + ); + + this.render(`{{x-foo}}`); + + this.$('#is-done').trigger('click'); + } + } +); + +moduleFor( + 'EventDispatcher#setup (classic component)', + class extends RenderingTestCase { + constructor() { + super(...arguments); + + this.dispatcher = this.owner.lookup('event_dispatcher:main'); + } + + getBootOptions() { + return { + skipEventDispatcher: true, + }; + } + + ['@test additional events can be specified'](assert) { + this.dispatcher.setup({ myevent: 'myEvent' }); + + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(`

    Hello!

    `), + class extends EmberComponent { + myEvent() { + assert.ok(true, 'custom event was triggered'); + } + } + ) + ); + + this.render(`{{x-foo}}`); + + this.$('div').trigger('myevent'); + } + + ['@test default events can be disabled via `customEvents`'](assert) { + this.dispatcher.setup({ click: null }); + + this.owner.register( + 'component:x-foo', + setComponentTemplate( + precompileTemplate(`

    Hello!

    `), + class extends EmberComponent { + click() { + assert.ok(false, 'click method was called'); + } + + null() { + assert.ok(false, 'null method was called'); + } + + doubleClick() { + assert.ok(true, 'a non-disabled event is still handled properly'); + } + } + ) + ); + + this.render(`{{x-foo}}`); + + this.$('div').trigger('click'); + this.$('div').trigger('dblclick'); + } + } +); + +moduleFor( + 'EventDispatcher - Event Properties (classic component)', + class extends RenderingTestCase { + ['@test dataTransfer property is added to drop event'](assert) { + let receivedEvent; + this.owner.register( + 'component:x-foo', + class extends EmberComponent { + drop(event) { + receivedEvent = event; + } + } + ); + + this.render(`{{x-foo}}`); + + fireNativeWithDataTransfer(this.$('div')[0], 'drop', 'success'); + assert.equal(receivedEvent.dataTransfer, 'success'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/instrumentation-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/instrumentation-test.js similarity index 98% rename from packages/@ember/-internals/glimmer/tests/integration/components/instrumentation-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/classic/instrumentation-test.js index df8770ca99f..ff766a814d8 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/instrumentation-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/instrumentation-test.js @@ -8,7 +8,7 @@ import { import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; -import { Component } from '../../utils/helpers'; +import { Component } from '../../../utils/helpers'; moduleFor( 'Components instrumentation', diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/life-cycle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/life-cycle-test.js new file mode 100644 index 00000000000..d94d4715974 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/life-cycle-test.js @@ -0,0 +1,1640 @@ +import { classes, moduleFor, RenderingTestCase, runTask, strip } from 'internal-test-helpers'; + +import { schedule } from '@ember/runloop'; +import { set, setProperties } from '@ember/object'; +import { trackedArray } from '@ember/reactive/collections'; +import { getViewElement, getViewId } from '@ember/-internals/views'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +import { Component } from '../../../utils/helpers'; +import { addListener } from '@ember/-internals/metal'; + +class LifeCycleHooksTest extends RenderingTestCase { + constructor() { + super(...arguments); + this.hooks = []; + this.components = {}; + this.componentRegistry = []; + this.teardownAssertions = []; + this.viewRegistry = this.owner.lookup('-view-registry:main'); + } + + afterEach() { + super.afterEach(); + + for (let i = 0; i < this.teardownAssertions.length; i++) { + this.teardownAssertions[i](); + } + } + + get isInteractive() { + return true; + } + + getBootOptions() { + return { + isInteractive: this.isInteractive, + }; + } + + /* abstract */ + get ComponentClass() { + throw new Error('Not implemented: `ComponentClass`'); + } + + /* abstract */ + invocationFor(/* name, namedArgs = {} */) { + throw new Error('Not implemented: `invocationFor`'); + } + + /* abstract */ + attrFor(/* name */) { + throw new Error('Not implemented: `attrFor`'); + } + + get boundHelpers() { + return { + invoke: bind(this.invocationFor, this), + attr: bind(this.attrFor, this), + }; + } + + assertRegisteredViews(label) { + let viewRegistry = this.viewRegistry; + let topLevelId = getViewId(this.component); + let actual = Object.keys(viewRegistry) + .sort() + .filter((id) => id !== topLevelId); + + if (this.isInteractive) { + let expected = this.componentRegistry.sort(); + + this.assert.deepEqual(actual, expected, 'registered views - ' + label); + } else { + this.assert.deepEqual(actual, [], 'no views should be registered for non-interactive mode'); + } + } + + registerComponent(name, { template = null }) { + let pushComponent = (instance) => { + this.components[name] = instance; + this.componentRegistry.push(getViewId(instance)); + }; + + let removeComponent = (instance) => { + let index = this.componentRegistry.indexOf(getViewId(instance)); + this.componentRegistry.splice(index, 1); + + delete this.components[name]; + }; + + let pushHook = (hookName, args) => { + this.hooks.push(hook(name, hookName, args)); + }; + + let assertParentView = (hookName, instance) => { + this.assert.ok(instance.parentView, `parentView should be present in ${hookName}`); + + if (hookName === 'willDestroyElement') { + this.assert.ok( + instance.parentView.childViews.indexOf(instance) !== -1, + `view is still connected to parentView in ${hookName}` + ); + } + }; + + let assertElement = (hookName, instance, inDOM = true) => { + if (instance.tagName === '') { + return; + } + + this.assert.ok( + getViewElement(instance), + `element should be present on ${instance} during ${hookName}` + ); + + if (this.isInteractive) { + this.assert.ok( + instance.element, + `this.element should be present on ${instance} during ${hookName}` + ); + this.assert.equal( + document.body.contains(instance.element), + inDOM, + `element for ${instance} ${ + inDOM ? 'should' : 'should not' + } be in the DOM during ${hookName}` + ); + } else { + this.assert.throws( + () => instance.element, + /Accessing `this.element` is not allowed in non-interactive environments/ + ); + } + }; + + let assertNoElement = (hookName, instance) => { + this.assert.strictEqual( + getViewElement(instance), + null, + `element should not be present in ${hookName}` + ); + + if (this.isInteractive) { + this.assert.strictEqual( + instance.element, + null, + `this.element should not be present in ${hookName}` + ); + } else { + this.assert.throws( + () => instance.element, + /Accessing `this.element` is not allowed in non-interactive environments/ + ); + } + }; + + let assertState = (hookName, expectedState, instance) => { + this.assert.equal( + instance._state, + expectedState, + `within ${hookName} the expected _state is ${expectedState}` + ); + }; + + let { isInteractive } = this; + + let ComponentClass = class extends this.ComponentClass { + init() { + super.init(...arguments); + + this.isInitialRender = true; + this.componentName = name; + pushHook('init'); + pushComponent(this); + assertParentView('init', this); + assertNoElement('init', this); + assertState('init', 'preRender', this); + + addListener(this, 'init', () => pushHook('on(init)')); + + schedule('afterRender', () => { + this.isInitialRender = false; + }); + } + + didReceiveAttrs(options) { + pushHook('didReceiveAttrs', options); + assertParentView('didReceiveAttrs', this); + + if (this.isInitialRender) { + assertNoElement('didReceiveAttrs', this); + assertState('didReceiveAttrs', 'preRender', this); + } else { + assertElement('didReceiveAttrs', this); + + if (isInteractive) { + assertState('didReceiveAttrs', 'inDOM', this); + } else { + assertState('didReceiveAttrs', 'hasElement', this); + } + } + } + + willInsertElement() { + pushHook('willInsertElement'); + assertParentView('willInsertElement', this); + assertElement('willInsertElement', this, false); + assertState('willInsertElement', 'hasElement', this); + } + + willRender() { + pushHook('willRender'); + assertParentView('willRender', this); + + if (this.isInitialRender) { + assertNoElement('willRender', this, false); + assertState('willRender', 'preRender', this); + } else { + assertElement('willRender', this); + assertState('willRender', 'inDOM', this); + } + } + + didInsertElement() { + pushHook('didInsertElement'); + assertParentView('didInsertElement', this); + assertElement('didInsertElement', this); + assertState('didInsertElement', 'inDOM', this); + } + + didRender() { + pushHook('didRender'); + assertParentView('didRender', this); + assertElement('didRender', this); + assertState('didRender', 'inDOM', this); + } + + didUpdateAttrs(options) { + pushHook('didUpdateAttrs', options); + assertParentView('didUpdateAttrs', this); + + if (isInteractive) { + assertState('didUpdateAttrs', 'inDOM', this); + } else { + assertState('didUpdateAttrs', 'hasElement', this); + } + } + + willUpdate(options) { + pushHook('willUpdate', options); + assertParentView('willUpdate', this); + assertElement('willUpdate', this); + assertState('willUpdate', 'inDOM', this); + } + + didUpdate(options) { + pushHook('didUpdate', options); + assertParentView('didUpdate', this); + assertElement('didUpdate', this); + assertState('didUpdate', 'inDOM', this); + } + + willDestroyElement() { + pushHook('willDestroyElement'); + assertParentView('willDestroyElement', this); + assertElement('willDestroyElement', this); + assertState('willDestroyElement', 'inDOM', this); + } + + willClearRender() { + pushHook('willClearRender'); + assertParentView('willClearRender', this); + assertElement('willClearRender', this); + assertState('willClearRender', 'inDOM', this); + } + + didDestroyElement() { + pushHook('didDestroyElement'); + assertNoElement('didDestroyElement', this); + assertState('didDestroyElement', 'destroying', this); + } + + willDestroy() { + pushHook('willDestroy'); + removeComponent(this); + + this._super(...arguments); + } + }; + + if (typeof template === 'string') { + setComponentTemplate(this.compile(template), ComponentClass); + } + this.owner.register(`component:${name}`, ComponentClass); + } + + assertHooks({ label, interactive, nonInteractive }) { + let rawHooks = this.isInteractive ? interactive : nonInteractive; + let hooks = rawHooks.map((raw) => hook(...raw)); + this.assert.deepEqual(json(this.hooks), json(hooks), label); + this.hooks = []; + } + + ['@test lifecycle hooks are invoked in a predictable order']() { + let { attr, invoke } = this.boundHelpers; + + this.registerComponent('the-top', { + template: strip` +
    + Twitter: {{${attr('twitter')}}}| + ${invoke('the-middle', { name: string('Tom Dale') })} +
    `, + }); + + this.registerComponent('the-middle', { + template: strip` +
    + Name: {{${attr('name')}}}| + ${invoke('the-bottom', { website: string('tomdale.net') })} +
    `, + }); + + this.registerComponent('the-bottom', { + template: strip` +
    + Website: {{${attr('website')}}} +
    `, + }); + + this.render(invoke('the-top', { twitter: expr(attr('twitter')) }), { + twitter: '@tomdale', + }); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + this.assertRegisteredViews('intial render'); + + this.assertHooks({ + label: 'after initial render', + + interactive: [ + // Sync hooks + + ['the-top', 'init'], + ['the-top', 'on(init)'], + ['the-top', 'didReceiveAttrs'], + ['the-top', 'willRender'], + ['the-top', 'willInsertElement'], + + ['the-middle', 'init'], + ['the-middle', 'on(init)'], + ['the-middle', 'didReceiveAttrs'], + ['the-middle', 'willRender'], + ['the-middle', 'willInsertElement'], + + ['the-bottom', 'init'], + ['the-bottom', 'on(init)'], + ['the-bottom', 'didReceiveAttrs'], + ['the-bottom', 'willRender'], + ['the-bottom', 'willInsertElement'], + + // Async hooks + + ['the-bottom', 'didInsertElement'], + ['the-bottom', 'didRender'], + + ['the-middle', 'didInsertElement'], + ['the-middle', 'didRender'], + + ['the-top', 'didInsertElement'], + ['the-top', 'didRender'], + ], + + nonInteractive: [ + // Sync hooks + ['the-top', 'init'], + ['the-top', 'on(init)'], + ['the-top', 'didReceiveAttrs'], + + ['the-middle', 'init'], + ['the-middle', 'on(init)'], + ['the-middle', 'didReceiveAttrs'], + + ['the-bottom', 'init'], + ['the-bottom', 'on(init)'], + ['the-bottom', 'didReceiveAttrs'], + ], + }); + + runTask(() => this.components['the-bottom'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (bottom)', + + interactive: [ + // Sync hooks + ['the-top', 'willUpdate'], + ['the-top', 'willRender'], + + ['the-middle', 'willUpdate'], + ['the-middle', 'willRender'], + + ['the-bottom', 'willUpdate'], + ['the-bottom', 'willRender'], + + // Async hooks + + ['the-bottom', 'didUpdate'], + ['the-bottom', 'didRender'], + + ['the-middle', 'didUpdate'], + ['the-middle', 'didRender'], + + ['the-top', 'didUpdate'], + ['the-top', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => this.components['the-middle'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (middle)', + + interactive: [ + // Sync hooks + + ['the-top', 'willUpdate'], + ['the-top', 'willRender'], + + ['the-middle', 'willUpdate'], + ['the-middle', 'willRender'], + + // Async hooks + + ['the-middle', 'didUpdate'], + ['the-middle', 'didRender'], + + ['the-top', 'didUpdate'], + ['the-top', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => this.components['the-top'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (top)', + + interactive: [ + // Sync hooks + + ['the-top', 'willUpdate'], + ['the-top', 'willRender'], + + // Async hooks + + ['the-top', 'didUpdate'], + ['the-top', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => set(this.context, 'twitter', '@horsetomdale')); + + this.assertText('Twitter: @horsetomdale|Name: Tom Dale|Website: tomdale.net'); + + // Because the `twitter` attr is only used by the topmost component, + // and not passed down, we do not expect to see lifecycle hooks + // called for child components. If the `didReceiveAttrs` hook used + // the new attribute to rerender itself imperatively, that would result + // in lifecycle hooks being invoked for the child. + + this.assertHooks({ + label: 'after update', + + interactive: [ + // Sync hooks + + ['the-top', 'didUpdateAttrs'], + ['the-top', 'didReceiveAttrs'], + + ['the-top', 'willUpdate'], + ['the-top', 'willRender'], + + // Async hooks + + ['the-top', 'didUpdate'], + ['the-top', 'didRender'], + ], + + nonInteractive: [ + // Sync hooks + ['the-top', 'didUpdateAttrs'], + ['the-top', 'didReceiveAttrs'], + ], + }); + + this.teardownAssertions.push(() => { + this.assertHooks({ + label: 'destroy', + + interactive: [ + ['the-top', 'willDestroyElement'], + ['the-top', 'willClearRender'], + ['the-middle', 'willDestroyElement'], + ['the-middle', 'willClearRender'], + ['the-bottom', 'willDestroyElement'], + ['the-bottom', 'willClearRender'], + ['the-top', 'didDestroyElement'], + ['the-middle', 'didDestroyElement'], + ['the-bottom', 'didDestroyElement'], + ['the-top', 'willDestroy'], + ['the-middle', 'willDestroy'], + ['the-bottom', 'willDestroy'], + ], + + nonInteractive: [ + ['the-top', 'willDestroy'], + ['the-middle', 'willDestroy'], + ['the-bottom', 'willDestroy'], + ], + }); + + this.assertRegisteredViews('after destroy'); + }); + } + + ['@test lifecycle hooks are invoked in a correct sibling order']() { + let { attr, invoke } = this.boundHelpers; + + this.registerComponent('the-parent', { + template: strip` +
    + ${invoke('the-first-child', { twitter: expr(attr('twitter')) })}| + ${invoke('the-second-child', { name: expr(attr('name')) })}| + ${invoke('the-last-child', { website: expr(attr('website')) })} +
    `, + }); + + this.registerComponent('the-first-child', { + template: `Twitter: {{${attr('twitter')}}}`, + }); + + this.registerComponent('the-second-child', { + template: `Name: {{${attr('name')}}}`, + }); + + this.registerComponent('the-last-child', { + template: `Website: {{${attr('website')}}}`, + }); + + this.render( + invoke('the-parent', { + twitter: expr(attr('twitter')), + name: expr(attr('name')), + website: expr(attr('website')), + }), + { + twitter: '@tomdale', + name: 'Tom Dale', + website: 'tomdale.net', + } + ); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + this.assertRegisteredViews('intial render'); + + this.assertHooks({ + label: 'after initial render', + + interactive: [ + // Sync hooks + + ['the-parent', 'init'], + ['the-parent', 'on(init)'], + ['the-parent', 'didReceiveAttrs'], + ['the-parent', 'willRender'], + ['the-parent', 'willInsertElement'], + + ['the-first-child', 'init'], + ['the-first-child', 'on(init)'], + ['the-first-child', 'didReceiveAttrs'], + ['the-first-child', 'willRender'], + ['the-first-child', 'willInsertElement'], + + ['the-second-child', 'init'], + ['the-second-child', 'on(init)'], + ['the-second-child', 'didReceiveAttrs'], + ['the-second-child', 'willRender'], + ['the-second-child', 'willInsertElement'], + + ['the-last-child', 'init'], + ['the-last-child', 'on(init)'], + ['the-last-child', 'didReceiveAttrs'], + ['the-last-child', 'willRender'], + ['the-last-child', 'willInsertElement'], + + // Async hooks + + ['the-first-child', 'didInsertElement'], + ['the-first-child', 'didRender'], + + ['the-second-child', 'didInsertElement'], + ['the-second-child', 'didRender'], + + ['the-last-child', 'didInsertElement'], + ['the-last-child', 'didRender'], + + ['the-parent', 'didInsertElement'], + ['the-parent', 'didRender'], + ], + + nonInteractive: [ + // Sync hooks + + ['the-parent', 'init'], + ['the-parent', 'on(init)'], + ['the-parent', 'didReceiveAttrs'], + + ['the-first-child', 'init'], + ['the-first-child', 'on(init)'], + ['the-first-child', 'didReceiveAttrs'], + + ['the-second-child', 'init'], + ['the-second-child', 'on(init)'], + ['the-second-child', 'didReceiveAttrs'], + + ['the-last-child', 'init'], + ['the-last-child', 'on(init)'], + ['the-last-child', 'didReceiveAttrs'], + ], + }); + + runTask(() => this.components['the-first-child'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (first child)', + + interactive: [ + // Sync hooks + + ['the-parent', 'willUpdate'], + ['the-parent', 'willRender'], + + ['the-first-child', 'willUpdate'], + ['the-first-child', 'willRender'], + + // Async hooks + + ['the-first-child', 'didUpdate'], + ['the-first-child', 'didRender'], + + ['the-parent', 'didUpdate'], + ['the-parent', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => this.components['the-second-child'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (second child)', + + interactive: [ + // Sync hooks + + ['the-parent', 'willUpdate'], + ['the-parent', 'willRender'], + + ['the-second-child', 'willUpdate'], + ['the-second-child', 'willRender'], + + // Async hooks + + ['the-second-child', 'didUpdate'], + ['the-second-child', 'didRender'], + + ['the-parent', 'didUpdate'], + ['the-parent', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => this.components['the-last-child'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (last child)', + + interactive: [ + // Sync hooks + + ['the-parent', 'willUpdate'], + ['the-parent', 'willRender'], + + ['the-last-child', 'willUpdate'], + ['the-last-child', 'willRender'], + + // Async hooks + + ['the-last-child', 'didUpdate'], + ['the-last-child', 'didRender'], + + ['the-parent', 'didUpdate'], + ['the-parent', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => this.components['the-parent'].rerender()); + + this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); + + this.assertHooks({ + label: 'after no-op rerender (parent)', + + interactive: [ + // Sync hooks + + ['the-parent', 'willUpdate'], + ['the-parent', 'willRender'], + + // Async hooks + + ['the-parent', 'didUpdate'], + ['the-parent', 'didRender'], + ], + + nonInteractive: [], + }); + + runTask(() => + setProperties(this.context, { + twitter: '@horsetomdale', + name: 'Horse Tom Dale', + website: 'horsetomdale.net', + }) + ); + + this.assertText('Twitter: @horsetomdale|Name: Horse Tom Dale|Website: horsetomdale.net'); + + this.assertHooks({ + label: 'after update', + + interactive: [ + // Sync hooks + + ['the-parent', 'didUpdateAttrs'], + ['the-parent', 'didReceiveAttrs'], + + ['the-parent', 'willUpdate'], + ['the-parent', 'willRender'], + + ['the-first-child', 'didUpdateAttrs'], + ['the-first-child', 'didReceiveAttrs'], + + ['the-first-child', 'willUpdate'], + ['the-first-child', 'willRender'], + + ['the-second-child', 'didUpdateAttrs'], + ['the-second-child', 'didReceiveAttrs'], + + ['the-second-child', 'willUpdate'], + ['the-second-child', 'willRender'], + + ['the-last-child', 'didUpdateAttrs'], + ['the-last-child', 'didReceiveAttrs'], + + ['the-last-child', 'willUpdate'], + ['the-last-child', 'willRender'], + + // Async hooks + + ['the-first-child', 'didUpdate'], + ['the-first-child', 'didRender'], + + ['the-second-child', 'didUpdate'], + ['the-second-child', 'didRender'], + + ['the-last-child', 'didUpdate'], + ['the-last-child', 'didRender'], + + ['the-parent', 'didUpdate'], + ['the-parent', 'didRender'], + ], + + nonInteractive: [ + // Sync hooks + + ['the-parent', 'didUpdateAttrs'], + ['the-parent', 'didReceiveAttrs'], + + ['the-first-child', 'didUpdateAttrs'], + ['the-first-child', 'didReceiveAttrs'], + + ['the-second-child', 'didUpdateAttrs'], + ['the-second-child', 'didReceiveAttrs'], + + ['the-last-child', 'didUpdateAttrs'], + ['the-last-child', 'didReceiveAttrs'], + ], + }); + + this.teardownAssertions.push(() => { + this.assertHooks({ + label: 'destroy', + + interactive: [ + ['the-parent', 'willDestroyElement'], + ['the-parent', 'willClearRender'], + ['the-first-child', 'willDestroyElement'], + ['the-first-child', 'willClearRender'], + ['the-second-child', 'willDestroyElement'], + ['the-second-child', 'willClearRender'], + ['the-last-child', 'willDestroyElement'], + ['the-last-child', 'willClearRender'], + + ['the-parent', 'didDestroyElement'], + ['the-first-child', 'didDestroyElement'], + ['the-second-child', 'didDestroyElement'], + ['the-last-child', 'didDestroyElement'], + + ['the-parent', 'willDestroy'], + ['the-first-child', 'willDestroy'], + ['the-second-child', 'willDestroy'], + ['the-last-child', 'willDestroy'], + ], + + nonInteractive: [ + ['the-parent', 'willDestroy'], + ['the-first-child', 'willDestroy'], + ['the-second-child', 'willDestroy'], + ['the-last-child', 'willDestroy'], + ], + }); + + this.assertRegisteredViews('after destroy'); + }); + } + + ['@test passing values through attrs causes lifecycle hooks to fire if the attribute values have changed']() { + let { attr, invoke } = this.boundHelpers; + + this.registerComponent('the-top', { + template: strip` +
    + Top: ${invoke('the-middle', { twitterTop: expr(attr('twitter')) })} +
    `, + }); + + this.registerComponent('the-middle', { + template: strip` +
    + Middle: ${invoke('the-bottom', { + twitterMiddle: expr(attr('twitterTop')), + })} +
    `, + }); + + this.registerComponent('the-bottom', { + template: strip` +
    + Bottom: {{${attr('twitterMiddle')}}} +
    `, + }); + + this.render(invoke('the-top', { twitter: expr(attr('twitter')) }), { + twitter: '@tomdale', + }); + + this.assertText('Top: Middle: Bottom: @tomdale'); + this.assertRegisteredViews('intial render'); + + this.assertHooks({ + label: 'after initial render', + + interactive: [ + // Sync hooks + + ['the-top', 'init'], + ['the-top', 'on(init)'], + ['the-top', 'didReceiveAttrs'], + ['the-top', 'willRender'], + ['the-top', 'willInsertElement'], + + ['the-middle', 'init'], + ['the-middle', 'on(init)'], + ['the-middle', 'didReceiveAttrs'], + ['the-middle', 'willRender'], + ['the-middle', 'willInsertElement'], + + ['the-bottom', 'init'], + ['the-bottom', 'on(init)'], + ['the-bottom', 'didReceiveAttrs'], + ['the-bottom', 'willRender'], + ['the-bottom', 'willInsertElement'], + + // Async hooks + + ['the-bottom', 'didInsertElement'], + ['the-bottom', 'didRender'], + + ['the-middle', 'didInsertElement'], + ['the-middle', 'didRender'], + + ['the-top', 'didInsertElement'], + ['the-top', 'didRender'], + ], + + nonInteractive: [ + // Sync hooks + + ['the-top', 'init'], + ['the-top', 'on(init)'], + ['the-top', 'didReceiveAttrs'], + + ['the-middle', 'init'], + ['the-middle', 'on(init)'], + ['the-middle', 'didReceiveAttrs'], + + ['the-bottom', 'init'], + ['the-bottom', 'on(init)'], + ['the-bottom', 'didReceiveAttrs'], + ], + }); + + runTask(() => set(this.context, 'twitter', '@horsetomdale')); + + this.assertText('Top: Middle: Bottom: @horsetomdale'); + + // Because the `twitter` attr is used by the all of the components, + // the lifecycle hooks are invoked for all components. + + this.assertHooks({ + label: 'after updating (root)', + + interactive: [ + // Sync hooks + + ['the-top', 'didUpdateAttrs'], + ['the-top', 'didReceiveAttrs'], + + ['the-top', 'willUpdate'], + ['the-top', 'willRender'], + + ['the-middle', 'didUpdateAttrs'], + ['the-middle', 'didReceiveAttrs'], + + ['the-middle', 'willUpdate'], + ['the-middle', 'willRender'], + + ['the-bottom', 'didUpdateAttrs'], + ['the-bottom', 'didReceiveAttrs'], + + ['the-bottom', 'willUpdate'], + ['the-bottom', 'willRender'], + + // Async hooks + + ['the-bottom', 'didUpdate'], + ['the-bottom', 'didRender'], + + ['the-middle', 'didUpdate'], + ['the-middle', 'didRender'], + + ['the-top', 'didUpdate'], + ['the-top', 'didRender'], + ], + + nonInteractive: [ + // Sync hooks + + ['the-top', 'didUpdateAttrs'], + ['the-top', 'didReceiveAttrs'], + + ['the-middle', 'didUpdateAttrs'], + ['the-middle', 'didReceiveAttrs'], + + ['the-bottom', 'didUpdateAttrs'], + ['the-bottom', 'didReceiveAttrs'], + ], + }); + + runTask(() => this.rerender()); + + this.assertText('Top: Middle: Bottom: @horsetomdale'); + + // In this case, because the attrs are passed down, all child components are invoked. + + this.assertHooks({ + label: 'after no-op rernder (root)', + interactive: [], + nonInteractive: [], + }); + + this.teardownAssertions.push(() => { + this.assertHooks({ + label: 'destroy', + + interactive: [ + ['the-top', 'willDestroyElement'], + ['the-top', 'willClearRender'], + ['the-middle', 'willDestroyElement'], + ['the-middle', 'willClearRender'], + ['the-bottom', 'willDestroyElement'], + ['the-bottom', 'willClearRender'], + ['the-top', 'didDestroyElement'], + ['the-middle', 'didDestroyElement'], + ['the-bottom', 'didDestroyElement'], + ['the-top', 'willDestroy'], + ['the-middle', 'willDestroy'], + ['the-bottom', 'willDestroy'], + ], + + nonInteractive: [ + ['the-top', 'willDestroy'], + ['the-middle', 'willDestroy'], + ['the-bottom', 'willDestroy'], + ], + }); + + this.assertRegisteredViews('after destroy'); + }); + } + + ['@test components rendered from `{{each}}` have correct life-cycle hooks to be called']() { + let { invoke } = this.boundHelpers; + + this.registerComponent('nested-item', { template: `{{yield}}` }); + + this.registerComponent('an-item', { + template: strip` + {{#nested-item}}Item: {{this.count}}{{/nested-item}} + `, + }); + + this.registerComponent('no-items', { + template: strip` + {{#nested-item}}Nothing to see here{{/nested-item}} + `, + }); + + this.render( + strip` + {{#each this.items as |item|}} + ${invoke('an-item', { count: expr('item') })} + {{else}} + ${invoke('no-items')} + {{/each}} + `, + { + items: [1, 2, 3, 4, 5], + } + ); + + this.assertText('Item: 1Item: 2Item: 3Item: 4Item: 5'); + this.assertRegisteredViews('intial render'); + + let initialHooks = () => { + let ret = [ + ['an-item', 'init'], + ['an-item', 'on(init)'], + ['an-item', 'didReceiveAttrs'], + ]; + if (this.isInteractive) { + ret.push(['an-item', 'willRender'], ['an-item', 'willInsertElement']); + } + ret.push( + ['nested-item', 'init'], + ['nested-item', 'on(init)'], + ['nested-item', 'didReceiveAttrs'] + ); + if (this.isInteractive) { + ret.push(['nested-item', 'willRender'], ['nested-item', 'willInsertElement']); + } + return ret; + }; + + let initialAfterRenderHooks = () => { + if (this.isInteractive) { + return [ + ['nested-item', 'didInsertElement'], + ['nested-item', 'didRender'], + ['an-item', 'didInsertElement'], + ['an-item', 'didRender'], + ]; + } else { + return []; + } + }; + + this.assertHooks({ + label: 'after initial render', + + interactive: [ + // Sync hooks + ...initialHooks(1), + ...initialHooks(2), + ...initialHooks(3), + ...initialHooks(4), + ...initialHooks(5), + + // Async hooks + ...initialAfterRenderHooks(5), + ...initialAfterRenderHooks(4), + ...initialAfterRenderHooks(3), + ...initialAfterRenderHooks(2), + ...initialAfterRenderHooks(1), + ], + + nonInteractive: [ + // Sync hooks + ...initialHooks(1), + ...initialHooks(2), + ...initialHooks(3), + ...initialHooks(4), + ...initialHooks(5), + + // Async hooks + ...initialAfterRenderHooks(5), + ...initialAfterRenderHooks(4), + ...initialAfterRenderHooks(3), + ...initialAfterRenderHooks(2), + ...initialAfterRenderHooks(1), + ], + }); + + // TODO: Is this correct? Should childViews be populated in non-interactive mode? + if (this.isInteractive) { + this.assert.equal(this.component.childViews.length, 5, 'childViews precond'); + } + + runTask(() => set(this.context, 'items', [])); + + // TODO: Is this correct? Should childViews be populated in non-interactive mode? + if (this.isInteractive) { + this.assert.equal(this.component.childViews.length, 1, 'childViews updated'); + } + + this.assertText('Nothing to see here'); + + this.assertHooks({ + label: 'reset to empty array', + + interactive: [ + ['an-item', 'willDestroyElement'], + ['an-item', 'willClearRender'], + ['nested-item', 'willDestroyElement'], + ['nested-item', 'willClearRender'], + ['an-item', 'willDestroyElement'], + ['an-item', 'willClearRender'], + ['nested-item', 'willDestroyElement'], + ['nested-item', 'willClearRender'], + ['an-item', 'willDestroyElement'], + ['an-item', 'willClearRender'], + ['nested-item', 'willDestroyElement'], + ['nested-item', 'willClearRender'], + ['an-item', 'willDestroyElement'], + ['an-item', 'willClearRender'], + ['nested-item', 'willDestroyElement'], + ['nested-item', 'willClearRender'], + ['an-item', 'willDestroyElement'], + ['an-item', 'willClearRender'], + ['nested-item', 'willDestroyElement'], + ['nested-item', 'willClearRender'], + + ['no-items', 'init'], + ['no-items', 'on(init)'], + ['no-items', 'didReceiveAttrs'], + ['no-items', 'willRender'], + ['no-items', 'willInsertElement'], + + ['nested-item', 'init'], + ['nested-item', 'on(init)'], + ['nested-item', 'didReceiveAttrs'], + ['nested-item', 'willRender'], + ['nested-item', 'willInsertElement'], + + ['nested-item', 'didInsertElement'], + ['nested-item', 'didRender'], + ['no-items', 'didInsertElement'], + ['no-items', 'didRender'], + + ['an-item', 'didDestroyElement'], + ['nested-item', 'didDestroyElement'], + ['an-item', 'didDestroyElement'], + ['nested-item', 'didDestroyElement'], + ['an-item', 'didDestroyElement'], + ['nested-item', 'didDestroyElement'], + ['an-item', 'didDestroyElement'], + ['nested-item', 'didDestroyElement'], + ['an-item', 'didDestroyElement'], + ['nested-item', 'didDestroyElement'], + + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ], + + nonInteractive: [ + ['no-items', 'init'], + ['no-items', 'on(init)'], + ['no-items', 'didReceiveAttrs'], + + ['nested-item', 'init'], + ['nested-item', 'on(init)'], + ['nested-item', 'didReceiveAttrs'], + + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ['an-item', 'willDestroy'], + ['nested-item', 'willDestroy'], + ], + }); + + this.teardownAssertions.push(() => { + this.assertHooks({ + label: 'destroy', + + interactive: [ + ['no-items', 'willDestroyElement'], + ['no-items', 'willClearRender'], + ['nested-item', 'willDestroyElement'], + ['nested-item', 'willClearRender'], + + ['no-items', 'didDestroyElement'], + ['nested-item', 'didDestroyElement'], + + ['no-items', 'willDestroy'], + ['nested-item', 'willDestroy'], + ], + + nonInteractive: [ + ['no-items', 'willDestroy'], + ['nested-item', 'willDestroy'], + ], + }); + + this.assertRegisteredViews('after destroy'); + }); + } +} + +class CurlyComponentsTest extends LifeCycleHooksTest { + get ComponentClass() { + return Component; + } + + invocationFor(name, namedArgs = {}) { + let attrs = Object.keys(namedArgs) + .map((k) => `${k}=${this.val(namedArgs[k])}`) + .join(' '); + return `{{${name} ${attrs}}}`; + } + + attrFor(name) { + return `this.${name}`; + } + + /* private */ + val(value) { + if (value.isString) { + return JSON.stringify(value.value); + } else if (value.isExpr) { + return `(readonly ${value.value})`; + } else { + throw new Error(`Unknown value: ${value}`); + } + } +} + +moduleFor( + 'Components test: interactive lifecycle hooks (curly components)', + class extends CurlyComponentsTest { + get isInteractive() { + return true; + } + } +); + +moduleFor( + 'Components test: non-interactive lifecycle hooks (curly components)', + class extends CurlyComponentsTest { + get isInteractive() { + return false; + } + } +); + +moduleFor( + 'Components test: interactive lifecycle hooks (tagless curly components)', + class extends CurlyComponentsTest { + get ComponentClass() { + return class extends Component { + tagName = ''; + }; + } + + get isInteractive() { + return true; + } + } +); + +moduleFor( + 'Components test: non-interactive lifecycle hooks (tagless curly components)', + class extends CurlyComponentsTest { + get ComponentClass() { + return class extends Component { + tagName = ''; + }; + } + + get isInteractive() { + return false; + } + } +); + +moduleFor( + 'Run loop and lifecycle hooks (classic component)', + class extends RenderingTestCase { + ['@test afterRender set']() { + let ComponentClass = class extends Component { + width = '5'; + didInsertElement() { + schedule('afterRender', () => { + this.set('width', '10'); + }); + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.width}}'), ComponentClass) + ); + + this.render('{{foo-bar}}'); + + this.assertText('10'); + + runTask(() => this.rerender()); + + this.assertText('10'); + } + + ['@test afterRender set on parent']() { + let ComponentClass = class extends Component { + didInsertElement() { + schedule('afterRender', () => { + let parent = this.get('parent'); + parent.set('foo', 'wat'); + }); + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.foo}}'), ComponentClass) + ); + + this.render('{{foo-bar parent=this foo=this.foo}}'); + + this.assertText('wat'); + + runTask(() => this.rerender()); + + this.assertText('wat'); + } + + ['@test `willRender` can set before render (GH#14458)']() { + let ComponentClass = class extends Component { + tagName = 'a'; + customHref = 'http://google.com'; + attributeBindings = ['customHref:href']; + willRender() { + this.set('customHref', 'http://willRender.com'); + } + }; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('Hello World'), ComponentClass) + ); + + this.render(`{{foo-bar id="foo"}}`); + + this.assertElement(this.firstChild, { + tagName: 'a', + attrs: { + id: 'foo', + href: 'http://willRender.com', + class: classes('ember-view'), + }, + }); + } + + ['@test that thing about destroying'](assert) { + let ParentDestroyedElements = []; + let ChildDestroyedElements = []; + + let ParentComponent = class extends Component { + willDestroyElement() { + ParentDestroyedElements.push({ + id: this.itemId, + name: 'parent-component', + hasParent: Boolean(this.element.parentNode), + nextSibling: Boolean(this.element.nextSibling), + previousSibling: Boolean(this.element.previousSibling), + }); + } + }; + + let NestedComponent = class extends Component { + willDestroyElement() { + ChildDestroyedElements.push({ + id: this.nestedId, + name: 'nested-component', + hasParent: Boolean(this.element.parentNode), + nextSibling: Boolean(this.element.nextSibling), + previousSibling: Boolean(this.element.previousSibling), + }); + } + }; + + this.owner.register( + 'component:parent-component', + setComponentTemplate( + precompileTemplate( + "{{yield}}
      {{#nested-component nestedId=(concat this.itemId '-A')}}A{{/nested-component}}{{#nested-component nestedId=(concat this.itemId '-B')}}B{{/nested-component}}
    " + ), + ParentComponent + ) + ); + + this.owner.register( + 'component:nested-component', + setComponentTemplate(precompileTemplate('{{yield}}'), NestedComponent) + ); + + let array = trackedArray([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]); + + this.render( + strip` + {{#each this.items as |item|}} + {{#parent-component itemId=item.id}}{{item.id}}{{/parent-component}} + {{/each}} + {{#if this.model.shouldShow}} + {{#parent-component itemId=6}}6{{/parent-component}} + {{/if}} + {{#if this.model.shouldShow}} + {{#parent-component itemId=7}}7{{/parent-component}} + {{/if}} + `, + { + items: array, + model: { shouldShow: true }, + } + ); + + this.assertText('1AB2AB3AB4AB5AB6AB7AB'); + + runTask(() => { + array.splice(2, 2); + set(this.context, 'model.shouldShow', false); + }); + + this.assertText('1AB2AB5AB'); + + assertDestroyHooks( + assert, + [...ParentDestroyedElements], + [ + { + id: 3, + hasParent: true, + nextSibling: true, + previousSibling: true, + }, + { + id: 4, + hasParent: true, + nextSibling: true, + previousSibling: true, + }, + { + id: 6, + hasParent: true, + nextSibling: true, + previousSibling: true, + }, + { + id: 7, + hasParent: true, + nextSibling: false, + previousSibling: true, + }, + ] + ); + + assertDestroyHooks( + assert, + [...ChildDestroyedElements], + [ + { + id: '3-A', + hasParent: true, + nextSibling: true, + previousSibling: false, + }, + { + id: '3-B', + hasParent: true, + nextSibling: false, + previousSibling: true, + }, + { + id: '4-A', + hasParent: true, + nextSibling: true, + previousSibling: false, + }, + { + id: '4-B', + hasParent: true, + nextSibling: false, + previousSibling: true, + }, + { + id: '6-A', + hasParent: true, + nextSibling: true, + previousSibling: false, + }, + { + id: '6-B', + hasParent: true, + nextSibling: false, + previousSibling: true, + }, + { + id: '7-A', + hasParent: true, + nextSibling: true, + previousSibling: false, + }, + { + id: '7-B', + hasParent: true, + nextSibling: false, + previousSibling: true, + }, + ] + ); + } + + ['@test lifecycle hooks exist on the base class'](assert) { + // Make sure we get the finalized component prototype + let prototype = Component.proto(); + + assert.equal(typeof prototype.didDestroyElement, 'function', 'didDestroyElement exists'); + assert.equal(typeof prototype.didInsertElement, 'function', 'didInsertElement exists'); + assert.equal(typeof prototype.didReceiveAttrs, 'function', 'didReceiveAttrs exists'); + assert.equal(typeof prototype.didRender, 'function', 'didRender exists'); + assert.equal(typeof prototype.didUpdate, 'function', 'didUpdate exists'); + assert.equal(typeof prototype.didUpdateAttrs, 'function', 'didUpdateAttrs exists'); + assert.equal(typeof prototype.willClearRender, 'function', 'willClearRender exists'); + assert.equal(typeof prototype.willDestroy, 'function', 'willDestroy exists'); + assert.equal(typeof prototype.willDestroyElement, 'function', 'willDestroyElement exists'); + assert.equal(typeof prototype.willInsertElement, 'function', 'willInsertElement exists'); + assert.equal(typeof prototype.willRender, 'function', 'willRender exists'); + assert.equal(typeof prototype.willUpdate, 'function', 'willUpdate exists'); + } + } +); + +function assertDestroyHooks(assert, _actual, _expected) { + _expected.forEach((expected, i) => { + let name = expected.name; + assert.equal(expected.id, _actual[i].id, `${name} id is the same`); + assert.equal(expected.hasParent, _actual[i].hasParent, `${name} has parent node`); + assert.equal(expected.nextSibling, _actual[i].nextSibling, `${name} has next sibling node`); + assert.equal( + expected.previousSibling, + _actual[i].previousSibling, + `${name} has previous sibling node` + ); + }); +} + +function bind(func, thisArg) { + return (...args) => func.apply(thisArg, args); +} + +function string(value) { + return { isString: true, value }; +} + +function expr(value) { + return { isExpr: true, value }; +} + +function hook(name, hook, { attrs, oldAttrs, newAttrs } = {}) { + return { name, hook, args: { attrs, oldAttrs, newAttrs } }; +} + +function json(serializable) { + return JSON.parse(JSON.stringify(serializable)); +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/mut-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/mut-test.js new file mode 100644 index 00000000000..8c0f11a9795 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/mut-test.js @@ -0,0 +1,613 @@ +import { RenderingTestCase, moduleFor, styles, runTask } from 'internal-test-helpers'; +import { set, get, computed } from '@ember/object'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; +import { Component as EmberComponent, htmlSafe } from '../../../utils/helpers'; + +moduleFor( + 'Helpers test: {{mut}} (classic component)', + class extends RenderingTestCase { + ['@test a simple mutable binding using `mut` propagates properly']() { + let bottom; + + this.owner.register( + 'component:bottom-mut', + setComponentTemplate( + precompileTemplate('{{this.setMe}}'), + class extends EmberComponent { + didInsertElement() { + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:middle-mut', + setComponentTemplate( + precompileTemplate('{{bottom-mut setMe=this.value}}'), + class extends EmberComponent {} + ) + ); + + this.render('{{middle-mut value=(mut this.val)}}', { + val: 12, + }); + + this.assertText('12', 'the data propagated downwards'); + + this.assertStableRerender(); + + runTask(() => bottom.attrs.setMe.update(13)); + + this.assertText('13', 'the set took effect'); + this.assert.strictEqual(get(bottom, 'setMe'), 13, "the set took effect on bottom's prop"); + this.assert.strictEqual(bottom.attrs.setMe.value, 13, "the set took effect on bottom's attr"); + this.assert.strictEqual(get(this.context, 'val'), 13, 'the set propagated back up'); + + runTask(() => set(bottom, 'setMe', 14)); + + this.assertText('14', 'the set took effect'); + this.assert.strictEqual(get(bottom, 'setMe'), 14, "the set took effect on bottom's prop"); + this.assert.strictEqual(bottom.attrs.setMe.value, 14, "the set took effect on bottom's attr"); + this.assert.strictEqual(get(this.context, 'val'), 14, 'the set propagated back up'); + + runTask(() => set(this.context, 'val', 12)); + + this.assertText('12'); + } + + ['@test a simple mutable binding using `mut` inserts into the DOM']() { + let bottom, middle; + + this.owner.register( + 'component:bottom-mut', + setComponentTemplate( + precompileTemplate('{{this.setMe}}'), + class extends EmberComponent { + didInsertElement() { + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:middle-mut', + setComponentTemplate( + precompileTemplate('{{bottom-mut setMe=(mut this.value)}}'), + class extends EmberComponent { + didInsertElement() { + middle = this; + } + } + ) + ); + + this.render('{{middle-mut value=(mut this.val)}}', { + val: 12, + }); + + this.assertText('12', 'the data propagated downwards'); + + this.assertStableRerender(); + + runTask(() => bottom.attrs.setMe.update(13)); + + this.assertText('13', 'the set took effect'); + this.assert.strictEqual(get(bottom, 'setMe'), 13, "the set took effect on bottom's prop"); + this.assert.strictEqual(bottom.attrs.setMe.value, 13, "the set took effect on bottom's attr"); + this.assert.strictEqual(get(middle, 'value'), 13, "the set propagated to middle's prop"); + this.assert.strictEqual(middle.attrs.value.value, 13, "the set propagated to middle's attr"); + this.assert.strictEqual(get(this.context, 'val'), 13, 'the set propagated back up'); + + runTask(() => set(bottom, 'setMe', 14)); + + this.assertText('14', 'the set took effect'); + this.assert.strictEqual(get(bottom, 'setMe'), 14, "the set took effect on bottom's prop"); + this.assert.strictEqual(bottom.attrs.setMe.value, 14, "the set took effect on bottom's attr"); + this.assert.strictEqual(get(middle, 'value'), 14, "the set propagated to middle's prop"); + this.assert.strictEqual(middle.attrs.value.value, 14, "the set propagated to middle's attr"); + this.assert.strictEqual(get(this.context, 'val'), 14, 'the set propagated back up'); + + runTask(() => set(this.context, 'val', 12)); + + this.assertText('12'); + } + + // See https://github.com/emberjs/ember.js/commit/807a0cd for an explanation of this test + ['@test using a string value through middle tier does not trigger assertion (due to the auto-mut transform)']() { + let bottom; + + this.owner.register( + 'component:bottom-mut', + setComponentTemplate( + precompileTemplate('{{this.stuff}}'), + class extends EmberComponent { + didInsertElement() { + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:middle-mut', + setComponentTemplate( + precompileTemplate('{{bottom-mut stuff=this.value}}'), + class extends EmberComponent {} + ) + ); + + this.render('{{middle-mut value="foo"}}'); + + this.assert.equal(get(bottom, 'stuff'), 'foo', 'the data propagated'); + this.assertText('foo'); + + this.assertStableRerender(); + + // No U-R for this test + } + + ['@test {{readonly}} of a {{mut}} is converted into an immutable binding']() { + let middle, bottom; + + this.owner.register( + 'component:bottom-mut', + setComponentTemplate( + precompileTemplate('{{this.setMe}}'), + class extends EmberComponent { + didInsertElement() { + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:middle-mut', + setComponentTemplate( + precompileTemplate('{{bottom-mut setMe=(readonly this.value)}}'), + class extends EmberComponent { + didInsertElement() { + middle = this; + } + } + ) + ); + + this.render('{{middle-mut value=(mut this.val)}}', { + val: 12, + }); + + this.assertText('12'); + + this.assertStableRerender(); + + runTask(() => middle.attrs.value.update(13)); + + this.assert.strictEqual(get(middle, 'value'), 13, "the set took effect on middle's prop"); + this.assert.strictEqual(middle.attrs.value.value, 13, "the set took effect on middle's attr"); + + runTask(() => set(middle, 'value', 14)); + + this.assert.strictEqual(get(middle, 'value'), 14, "the set took effect on middle's prop"); + this.assert.strictEqual(middle.attrs.value.value, 14, "the set took effect on middle's attr"); + this.assert.strictEqual( + bottom.attrs.setMe, + 14, + 'the mutable binding has been converted to an immutable cell' + ); + this.assertText('14'); + this.assert.strictEqual(get(this.context, 'val'), 14, 'the set propagated back up'); + + runTask(() => set(this.context, 'val', 12)); + + this.assertText('12'); + } + + ['@test a simple mutable binding using {{mut}} is available in hooks']() { + let bottom; + let willRender = []; + let didInsert = []; + + this.owner.register( + 'component:bottom-mut', + setComponentTemplate( + precompileTemplate('{{this.setMe}}'), + class extends EmberComponent { + willRender() { + willRender.push(get(this, 'setMe')); + } + didInsertElement() { + didInsert.push(get(this, 'setMe')); + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:middle-mut', + setComponentTemplate( + precompileTemplate('{{bottom-mut setMe=(mut this.value)}}'), + class extends EmberComponent {} + ) + ); + + this.render('{{middle-mut value=(mut this.val)}}', { + val: 12, + }); + + this.assert.deepEqual(willRender, [12], 'willReceive is [12]'); + this.assert.deepEqual(didInsert, [12], 'didInsert is [12]'); + this.assertText('12'); + + this.assertStableRerender(); + + this.assert.deepEqual(willRender, [12], 'willReceive is [12]'); + this.assert.deepEqual(didInsert, [12], 'didInsert is [12]'); + this.assert.strictEqual(get(bottom, 'setMe'), 12, 'the data propagated'); + + runTask(() => bottom.attrs.setMe.update(13)); + + this.assert.strictEqual(get(bottom, 'setMe'), 13, "the set took effect on bottom's prop"); + this.assert.strictEqual(bottom.attrs.setMe.value, 13, "the set took effect on bottom's attr"); + this.assert.strictEqual(get(this.context, 'val'), 13, 'the set propagated back up'); + + runTask(() => set(bottom, 'setMe', 14)); + + this.assert.strictEqual(get(bottom, 'setMe'), 14, "the set took effect on bottom's prop"); + this.assert.strictEqual(bottom.attrs.setMe.value, 14, "the set took effect on bottom's attr"); + this.assert.strictEqual(get(this.context, 'val'), 14, 'the set propagated back up'); + + runTask(() => set(this.context, 'val', 12)); + + this.assertText('12'); + } + + ['@test a mutable binding with a backing computed property and attribute present in the root of the component is updated when the upstream property invalidates #11023']() { + let bottom, middle; + + this.owner.register( + 'component:bottom-mut', + setComponentTemplate( + precompileTemplate('{{this.thingy}}'), + class extends EmberComponent { + thingy = null; + didInsertElement() { + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:middle-mut', + setComponentTemplate( + precompileTemplate('{{bottom-mut thingy=(mut this.val)}}'), + class extends EmberComponent { + baseValue = 12; + @computed('baseValue') + get val() { + return this.baseValue; + } + didInsertElement() { + middle = this; + } + } + ) + ); + + this.render('{{middle-mut}}'); + + this.assert.strictEqual(get(bottom, 'thingy'), 12, 'data propagated'); + this.assertText('12'); + + this.assertStableRerender(); + + runTask(() => set(middle, 'baseValue', 13)); + + this.assert.strictEqual(get(middle, 'val'), 13, 'the set took effect'); + this.assert.strictEqual( + bottom.attrs.thingy.value, + 13, + "the set propagated down to bottom's attrs" + ); + this.assert.strictEqual( + get(bottom, 'thingy'), + 13, + "the set propagated down to bottom's prop" + ); + this.assertText('13'); + + runTask(() => set(middle, 'baseValue', 12)); + + this.assertText('12'); + } + + ['@test automatic mutable bindings exposes a mut cell in attrs']() { + let inner; + + this.owner.register( + 'component:x-inner', + setComponentTemplate( + precompileTemplate('{{this.foo}}'), + class extends EmberComponent { + didInsertElement() { + inner = this; + } + } + ) + ); + + this.owner.register( + 'component:x-outer', + setComponentTemplate( + precompileTemplate('{{x-inner foo=this.bar}}'), + class extends EmberComponent {} + ) + ); + + this.render('{{x-outer bar=this.baz}}', { baz: 'foo' }); + + this.assertText('foo'); + + this.assertStableRerender(); + + runTask(() => inner.attrs.foo.update('bar')); + + this.assert.equal(inner.attrs.foo.value, 'bar'); + this.assert.equal(get(inner, 'foo'), 'bar'); + this.assertText('bar'); + + runTask(() => inner.attrs.foo.update('foo')); + + this.assertText('foo'); + } + + ['@test automatic mutable bindings tolerate undefined non-stream inputs and attempts to set them']() { + let inner; + + this.owner.register( + 'component:x-inner', + setComponentTemplate( + precompileTemplate('{{@model}}'), + class extends EmberComponent { + didInsertElement() { + inner = this; + } + } + ) + ); + + this.owner.register( + 'component:x-outer', + setComponentTemplate( + precompileTemplate('{{x-inner model=this.nonexistent}}'), + class extends EmberComponent {} + ) + ); + + this.render('{{x-outer}}'); + + this.assertText(''); + + this.assertStableRerender(); + + runTask(() => inner.attrs.model.update(42)); + + this.assert.equal(inner.attrs.model.value, 42); + this.assert.equal(get(inner, 'model'), 42); + this.assertText('42'); + + runTask(() => inner.attrs.model.update(undefined)); + + this.assertText(''); + } + + ['@test automatic mutable bindings tolerate constant non-stream inputs and attempts to set them']() { + let inner; + + this.owner.register( + 'component:x-inner', + setComponentTemplate( + precompileTemplate('hello{{@model}}'), + class extends EmberComponent { + didInsertElement() { + inner = this; + } + } + ) + ); + + this.owner.register( + 'component:x-outer', + setComponentTemplate( + precompileTemplate( + // Use `this.x` here instead of `@x` to let `x-inner` mutate `this.x`. + // `@x` points to the literal binding from `x-outer`, which is of + // course immutable. + '{{x-inner model=this.x}}' + ), + class extends EmberComponent {} + ) + ); + + this.render('{{x-outer x="foo"}}'); + + this.assertText('hellofoo'); + + this.assertStableRerender(); + + runTask(() => inner.attrs.model.update(42)); + + this.assert.equal(inner.attrs.model.value, 42); + this.assert.equal(get(inner, 'model'), 42); + this.assertText('hello42'); + + runTask(() => inner.attrs.model.update('foo')); + + this.assertText('hellofoo'); + } + } +); + +moduleFor( + 'Mutable Bindings used in Computed Properties that are bound as attributeBindings (classic component)', + class extends RenderingTestCase { + ['@test an attribute binding of a computed property of a 2-way bound attr recomputes when the attr changes']() { + let input, output; + + this.owner.register( + 'component:x-input', + class extends EmberComponent { + didInsertElement() { + input = this; + } + } + ); + + this.owner.register( + 'component:x-output', + setComponentTemplate( + precompileTemplate('{{this.height}}'), + class extends EmberComponent { + attributeBindings = ['style']; + didInsertElement() { + output = this; + } + @computed('height') + get style() { + let height = this.get('height'); + return htmlSafe(`height: ${height}px;`); + } + height = 20; + } + ) + ); + + this.render('{{x-output height=this.height}}{{x-input height=(mut this.height)}}', { + height: 60, + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 60px;') }, + content: '60', + }); + + this.assertStableRerender(); + + runTask(() => input.attrs.height.update(35)); + + this.assert.strictEqual(get(output, 'height'), 35, 'the set took effect'); + this.assert.strictEqual(get(this.context, 'height'), 35, 'the set propagated back up'); + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 35px;') }, + content: '35', + }); + + runTask(() => set(input, 'height', 36)); + + this.assert.strictEqual(get(output, 'height'), 36, 'the set took effect'); + this.assert.strictEqual(get(this.context, 'height'), 36, 'the set propagated back up'); + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 36px;') }, + content: '36', + }); + + runTask(() => set(this.context, 'height', 60)); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 60px;') }, + content: '60', + }); + this.assert.strictEqual(get(input, 'height'), 60); + } + + ['@test an attribute binding of a computed property with a setter of a 2-way bound attr recomputes when the attr changes']() { + let input, output; + + this.owner.register( + 'component:x-input', + class extends EmberComponent { + didInsertElement() { + input = this; + } + } + ); + + this.owner.register( + 'component:x-output', + setComponentTemplate( + precompileTemplate('{{this.width}}x{{this.height}}'), + class extends EmberComponent { + attributeBindings = ['style']; + didInsertElement() { + output = this; + } + @computed('height', 'width') + get style() { + let height = this.get('height'); + let width = this.get('width'); + return htmlSafe(`height: ${height}px; width: ${width}px;`); + } + height = 20; + @computed('height') + get width() { + return this.get('height') * 2; + } + set width(width) { + this.set('height', width / 2); + } + } + ) + ); + + this.render('{{x-output width=this.width}}{{x-input width=(mut this.width)}}', { + width: 70, + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 35px; width: 70px;') }, + content: '70x35', + }); + + this.assertStableRerender(); + + runTask(() => set(input, 'width', 80)); + + this.assert.strictEqual(get(output, 'width'), 80, 'the set took effect'); + this.assert.strictEqual(get(this.context, 'width'), 80, 'the set propagated back up'); + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 40px; width: 80px;') }, + content: '80x40', + }); + + runTask(() => input.attrs.width.update(90)); + + this.assert.strictEqual(get(output, 'width'), 90, 'the set took effect'); + this.assert.strictEqual(get(this.context, 'width'), 90, 'the set propagated back up'); + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 45px; width: 90px;') }, + content: '90x45', + }); + + runTask(() => set(this.context, 'width', 70)); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { style: styles('height: 35px; width: 70px;') }, + content: '70x35', + }); + this.assert.strictEqual(get(input, 'width'), 70); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/readonly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/readonly-test.js new file mode 100644 index 00000000000..fcd3671cccb --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/readonly-test.js @@ -0,0 +1,271 @@ +import { RenderingTestCase, moduleFor, runTask } from 'internal-test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +import { set, get } from '@ember/object'; + +import { Component } from '../../../utils/helpers'; + +moduleFor( + 'Helpers test: {{readonly}} (classic component)', + class extends RenderingTestCase { + ['@test {{readonly}} of a path should work']() { + let component; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends Component { + didInsertElement() { + component = this; + } + } + ) + ); + + this.render('{{foo-bar value=(readonly this.val)}}', { + val: 12, + }); + + this.assertText('12'); + + this.assertStableRerender(); + + runTask(() => set(component, 'value', 13)); + this.assert.notOk(component.attrs.value.update); + + this.assertText('13', 'local property is updated'); + this.assert.equal(get(this.context, 'val'), 12, 'upstream attribute is not updated'); + + // No U-R + } + + '@test updating a {{readonly}} property from above works'(assert) { + let component; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends Component { + init() { + super.init(...arguments); + component = this; + } + } + ) + ); + + this.render('{{foo-bar value=(readonly this.thing)}}', { + thing: 'initial', + }); + + this.assertText('initial'); + + this.assertStableRerender(); + + assert.strictEqual(component.attrs.value, 'initial', 'no mutable cell'); + assert.strictEqual(get(component, 'value'), 'initial', 'no mutable cell'); + assert.strictEqual(this.context.thing, 'initial'); + + runTask(() => set(this.context, 'thing', 'updated!')); + + this.assertText('updated!'); + assert.strictEqual(component.attrs.value, 'updated!', 'passed down value was set in attrs'); + assert.strictEqual(get(component, 'value'), 'updated!', 'passed down value was set'); + + runTask(() => set(this.context, 'thing', 'initial')); + + this.assertText('initial'); + } + + '@test updating a nested path of a {{readonly}}'(assert) { + let component; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.value.prop}}'), + class extends Component { + didInsertElement() { + component = this; + } + } + ) + ); + + this.render('{{foo-bar value=(readonly this.thing)}}', { + thing: { + prop: 'initial', + }, + }); + + this.assertText('initial'); + + this.assertStableRerender(); + + assert.notOk(component.attrs.value.update, 'no update available'); + assert.deepEqual(get(component, 'value'), { prop: 'initial' }); + assert.deepEqual(this.context.thing, { prop: 'initial' }); + + runTask(() => set(component, 'value.prop', 'updated!')); + + this.assertText('updated!', 'nested path is updated'); + assert.deepEqual(get(component, 'value'), { prop: 'updated!' }); + assert.deepEqual(this.context.thing, { prop: 'updated!' }); + + runTask(() => set(component, 'value.prop', 'initial')); + + this.assertText('initial'); + } + + ['@test {{readonly}} of a string renders correctly']() { + let component; + + this.owner.register( + 'component:foo-bar', + setComponentTemplate( + precompileTemplate('{{this.value}}'), + class extends Component { + didInsertElement() { + component = this; + } + } + ) + ); + + this.render('{{foo-bar value=(readonly "12")}}'); + + this.assertText('12'); + + this.assertStableRerender(); + + this.assert.notOk(component.attrs.value.update); + this.assert.strictEqual(get(component, 'value'), '12'); + + runTask(() => set(component, 'value', '13')); + + this.assertText('13', 'local property is updated'); + this.assert.strictEqual(get(component, 'value'), '13'); + + runTask(() => set(component, 'value', '12')); + + this.assertText('12'); + } + + ['@test {{mut}} of a {{readonly}} mutates only the middle and bottom tiers']() { + let middle, bottom; + + this.owner.register( + 'component:x-bottom', + setComponentTemplate( + precompileTemplate('{{this.bar}}'), + class extends Component { + didInsertElement() { + bottom = this; + } + } + ) + ); + + this.owner.register( + 'component:x-middle', + setComponentTemplate( + precompileTemplate('{{this.foo}} {{x-bottom bar=(mut this.foo)}}'), + class extends Component { + didInsertElement() { + middle = this; + } + } + ) + ); + + this.render('{{x-middle foo=(readonly this.val)}}', { + val: 12, + }); + + this.assertText('12 12'); + + this.assertStableRerender(); + + this.assert.equal(get(bottom, 'bar'), 12, "bottom's local bar received the value"); + this.assert.equal(get(middle, 'foo'), 12, "middle's local foo received the value"); + + // updating the mut-cell directly + runTask(() => bottom.attrs.bar.update(13)); + + this.assert.equal( + get(bottom, 'bar'), + 13, + "bottom's local bar was updated after set of bottom's bar" + ); + this.assert.equal( + get(middle, 'foo'), + 13, + "middle's local foo was updated after set of bottom's bar" + ); + this.assertText('13 13'); + this.assert.equal(get(this.context, 'val'), 12, 'But context val is not updated'); + + runTask(() => set(bottom, 'bar', 14)); + + this.assert.equal( + get(bottom, 'bar'), + 14, + "bottom's local bar was updated after set of bottom's bar" + ); + this.assert.equal( + get(middle, 'foo'), + 14, + "middle's local foo was updated after set of bottom's bar" + ); + this.assertText('14 14'); + this.assert.equal(get(this.context, 'val'), 12, 'But context val is not updated'); + + this.assert.notOk(middle.attrs.foo.update, "middle's foo attr is not a mutable cell"); + runTask(() => set(middle, 'foo', 15)); + + this.assertText('15 15'); + this.assert.equal(get(middle, 'foo'), 15, "set of middle's foo took effect"); + this.assert.equal( + get(bottom, 'bar'), + 15, + "bottom's local bar was updated after set of middle's foo" + ); + this.assert.equal(get(this.context, 'val'), 12, 'Context val remains unchanged'); + + runTask(() => set(this.context, 'val', 10)); + + this.assertText('10 10'); + this.assert.equal( + get(bottom, 'bar'), + 10, + "bottom's local bar was updated after set of context's val" + ); + this.assert.equal( + get(middle, 'foo'), + 10, + "middle's local foo was updated after set of context's val" + ); + + // setting as a normal property + runTask(() => set(bottom, 'bar', undefined)); + + this.assertText(' '); + this.assert.equal( + get(bottom, 'bar'), + undefined, + "bottom's local bar was updated to a falsy value" + ); + this.assert.equal( + get(middle, 'foo'), + undefined, + "middle's local foo was updated to a falsy value" + ); + + runTask(() => set(this.context, 'val', 12)); + this.assertText('12 12', 'bottom and middle were both reset'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/template-rendering-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/template-rendering-test.js new file mode 100644 index 00000000000..f0dfb6178e0 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/template-rendering-test.js @@ -0,0 +1,90 @@ +import Route from '@ember/routing/route'; +import Controller from '@ember/controller'; +import { moduleFor, ApplicationTestCase } from 'internal-test-helpers'; +import { run } from '@ember/runloop'; +import { Component as EmberComponent } from '@ember/-internals/glimmer'; +import { precompileTemplate } from '@ember/template-compilation'; + +moduleFor( + 'Route - template rendering (classic component)', + class extends ApplicationTestCase { + ['@test Components inside an outlet have their didInsertElement hook invoked when the route is displayed']( + assert + ) { + this.add( + 'template:index', + precompileTemplate( + '{{#if this.showFirst}}{{my-component}}{{else}}{{other-component}}{{/if}}' + ) + ); + + let myComponentCounter = 0; + let otherComponentCounter = 0; + let indexController; + + this.router.map(function () { + this.route('index', { path: '/' }); + }); + + this.add( + 'controller:index', + class extends Controller { + showFirst = true; + } + ); + + this.add( + 'route:index', + class extends Route { + setupController(controller) { + indexController = controller; + } + } + ); + + this.add( + 'component:my-component', + class extends EmberComponent { + didInsertElement() { + myComponentCounter++; + } + } + ); + + this.add( + 'component:other-component', + class extends EmberComponent { + didInsertElement() { + otherComponentCounter++; + } + } + ); + + return this.visit('/').then(() => { + assert.strictEqual( + myComponentCounter, + 1, + 'didInsertElement invoked on displayed component' + ); + assert.strictEqual( + otherComponentCounter, + 0, + 'didInsertElement not invoked on displayed component' + ); + + run(() => indexController.set('showFirst', false)); + + assert.strictEqual( + myComponentCounter, + 1, + 'didInsertElement not invoked on displayed component' + ); + assert.strictEqual( + otherComponentCounter, + 1, + 'didInsertElement invoked on displayed component' + ); + }); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/tracked-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/tracked-test.js new file mode 100644 index 00000000000..c580ec85c3f --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/tracked-test.js @@ -0,0 +1,51 @@ +import { tracked } from '@ember/-internals/metal'; +import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; +import { Component as EmberComponent } from '../../../utils/helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; + +moduleFor( + 'Component Tracked Properties (classic component)', + class extends RenderingTestCase { + '@test simple test using classic component'() { + let personId = 0; + class Person { + @tracked first; + @tracked last; + + constructor(first, last) { + this.id = personId++; + this.first = first; + this.last = last; + } + } + + class PersonComponent extends EmberComponent { + @tracked first; + @tracked last; + + get person() { + return new Person(this.first, this.last); + } + } + + this.owner.register( + 'component:person-wrapper', + setComponentTemplate( + precompileTemplate('{{@first}} {{@last}} | {{this.person.first}} {{this.person.last}}'), + PersonComponent + ) + ); + + this.render('', { + first: 'robert', + last: 'jackson', + }); + + this.assertText('robert jackson | robert jackson'); + + runTask(() => this.context.set('first', 'max')); + this.assertText('max jackson | max jackson'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/utils-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/utils-test.js similarity index 99% rename from packages/@ember/-internals/glimmer/tests/integration/components/utils-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/classic/utils-test.js index 790d09862ab..fd0c7fe77aa 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/utils-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/utils-test.js @@ -12,7 +12,7 @@ import { import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; -import { Component } from '../../utils/helpers'; +import { Component } from '../../../utils/helpers'; moduleFor( 'View tree tests', diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/classic/yield-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/classic/yield-test.js new file mode 100644 index 00000000000..6c2fb909f5e --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/classic/yield-test.js @@ -0,0 +1,33 @@ +import { RenderingTestCase, moduleFor } from 'internal-test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; +import { Component as EmberComponent } from '../../../utils/helpers'; + +moduleFor( + 'Helpers test: {{yield}} helper (classic component)', + class extends RenderingTestCase { + // INUR not need with no data update + ['@test yield should not introduce a view'](assert) { + let ParentCompComponent = class extends EmberComponent { + isParentComponent = true; + }; + + let ChildCompComponent = class extends EmberComponent { + didReceiveAttrs() { + super.didReceiveAttrs(); + let parentView = this.get('parentView'); + + assert.ok(parentView.get('isParentComponent')); + } + }; + + this.owner.register( + 'component:parent-comp', + setComponentTemplate(precompileTemplate('{{yield}}'), ParentCompComponent) + ); + this.owner.register('component:child-comp', ChildCompComponent); + + this.render('{{#parent-comp}}{{child-comp}}{{/parent-comp}}'); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/component-template-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/component-template-test.js index 0571b381e22..8a5085e2a53 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/component-template-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/component-template-test.js @@ -4,7 +4,6 @@ import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; import { setComponentTemplate, getComponentTemplate } from '@glimmer/manager'; import { precompileTemplate } from '@ember/template-compilation'; import Component from '@glimmer/component'; -import { Component as EmberComponent } from '../../utils/helpers'; moduleFor( 'Components test: setComponentTemplate', @@ -74,23 +73,6 @@ moduleFor( }, /Cannot call `setComponentTemplate` multiple times on the same class \(`Thing`\)/); } - '@test templates set with setComponentTemplate are inherited (EmberObject.extend())'() { - let Parent = setComponentTemplate( - precompileTemplate('hello'), - class extends EmberComponent {} - ); - - this.owner.register('component:foo-bar', class extends Parent {}); - - this.render(''); - - this.assertComponentElement(this.firstChild, { content: 'hello' }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { content: 'hello' }); - } - '@test templates set with setComponentTemplate are inherited (native ES class extends)'() { let Parent = setComponentTemplate(precompileTemplate('hello'), class extends Component {}); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/contextual-components-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/contextual-components-test.js index 9c1566896a8..ecabc49d499 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/contextual-components-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/contextual-components-test.js @@ -2,11 +2,9 @@ import { DEBUG } from '@glimmer/env'; import { moduleFor, RenderingTestCase, applyMixins, strip, runTask } from 'internal-test-helpers'; import { action } from '@ember/object'; -import { A as emberA } from '@ember/array'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { Component as EmberComponent } from '../../utils/helpers'; import PositionalComponent from '../../utils/positional-component'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; @@ -507,40 +505,6 @@ moduleFor( this.assertText('Inner 28'); } - ['@test conflicting positional and hash parameters does not raise an assertion if rerendered']() { - // In some cases, rerendering with a positional param used to cause an - // assertion. This test checks it does not. - this.owner.register( - 'component:-looked-up', - setComponentTemplate( - precompileTemplate('{{this.greeting}} {{this.name}}'), - class extends EmberComponent { - static positionalParams = ['name']; - } - ) - ); - - this.render('{{component (component "-looked-up" this.model.name greeting="Hodi")}}', { - model: { - name: 'Hodari', - }, - }); - - this.assertText('Hodi Hodari'); - - runTask(() => this.rerender()); - - this.assertText('Hodi Hodari'); - - runTask(() => this.context.set('model.name', 'Sergio')); - - this.assertText('Hodi Sergio'); - - runTask(() => this.context.set('model', { name: 'Hodari' })); - - this.assertText('Hodi Hodari'); - } - ['@test component with dynamic component name resolving to undefined, then an existing component']() { this.owner.register( 'component:foo-bar', @@ -804,13 +768,17 @@ moduleFor( this.owner.register( 'component:my-component', - class extends EmberComponent { - static positionalParams = ['value']; + setComponentTemplate( + precompileTemplate(''), + class extends PositionalComponent { + static positionalParams = ['value']; - didReceiveAttrs() { - value = this.getAttr('value'); + constructor(owner, args) { + super(owner, args); + value = this.value; + } } - } + ) ); this.render( @@ -828,14 +796,8 @@ moduleFor( this.owner.register( 'component:my-nested-component', setComponentTemplate( - precompileTemplate('{{this.myProp}}'), - class extends EmberComponent { - static positionalParams = ['my-parent-attr']; - - didReceiveAttrs() { - this.set('myProp', this.getAttr('my-parent-attr')); - } - } + precompileTemplate('{{@my-parent-attr}}'), + class extends Component {} ) ); @@ -853,21 +815,17 @@ moduleFor( 'component:my-action-component', setComponentTemplate( precompileTemplate( - '{{#my-component my-attr=this.myProp as |api|}}{{api.my-nested-component}}{{/my-component}}
    ' + '{{#my-component my-attr=@myProp as |api|}}{{api.my-nested-component}}{{/my-component}}
    ' ), - class extends EmberComponent { - @action - changeValue() { - this.incrementProperty('myProp'); - } - } + class extends Component {} ) ); - this.render('{{my-action-component myProp=this.model.myProp}}', { + this.render('{{my-action-component myProp=this.model.myProp changeValue=this.changeValue}}', { model: { myProp: 1, }, + changeValue: () => this.context.incrementProperty('model.myProp'), }); assert.equal(this.$('#nested-prop').text(), '1'); @@ -918,103 +876,6 @@ moduleFor( this.assertText('Foo'); } - ['@test parameters in a contextual component are mutable when value is a param'](assert) { - // This checks that a `(mut)` is added to parameters and attributes to - // contextual components when it is a param. - this.owner.register( - 'component:change-button', - setComponentTemplate( - precompileTemplate( - '' - ), - class extends EmberComponent { - static positionalParams = ['val']; - } - ) - ); - - this.render( - strip` - {{component (component "change-button" this.model.val2)}} - {{this.model.val2}}`, - { - model: { - val2: 8, - }, - } - ); - - assert.equal(this.$('.value').text(), '8'); - - runTask(() => this.rerender()); - - assert.equal(this.$('.value').text(), '8'); - - runTask(() => this.$('.my-button').click()); - - assert.equal(this.$('.value').text(), '10'); - - runTask(() => this.context.set('model', { val2: 8 })); - - assert.equal(this.$('.value').text(), '8'); - } - - ['@test tagless blockless components render'](assert) { - this.owner.register( - 'component:my-comp', - class extends EmberComponent { - tagName = ''; - } - ); - - this.render(`{{my-comp}}`); - - runTask(() => this.rerender()); - - assert.equal(this.$().text(), ''); - } - - ['@test GH#13494 tagless blockless component with property binding'](assert) { - this.owner.register( - 'component:outer-component', - setComponentTemplate( - precompileTemplate( - 'message: {{this.message}}{{inner-component message=this.message}}' - ), - class extends EmberComponent { - static positionalParams = ['val']; - } - ) - ); - } else { - this.owner.register( - 'component:change-button', - setComponentTemplate( - precompileTemplate( - '' - ), - class extends Component {} - ) - ); - } + this.owner.register( + 'component:change-button', + setComponentTemplate( + precompileTemplate( + '' + ), + class extends Component {} + ) + ); setup.call(this, assert); @@ -1648,32 +1447,6 @@ class MutableParamTestGenerator { applyMixins( ContextualComponentMutableParamsTest, new MutableParamTestGenerator([ - { - title: 'param', - positional: true, - setup() { - this.render('{{component (component "change-button" this.model.val2)}}'); - }, - }, - - { - title: 'nested param', - positional: true, - setup() { - this.owner.register( - 'component:my-comp', - setComponentTemplate( - precompileTemplate('{{component this.components.comp}}'), - class extends EmberComponent { - static positionalParams = ['components']; - } - ) - ); - - this.render('{{my-comp (hash comp=(component "change-button" this.model.val2))}}'); - }, - }, - { title: 'hash value', setup() { diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/curly-components-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/curly-components-test.js index aa8574fba41..fb5dd168b0b 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/curly-components-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/curly-components-test.js @@ -2,27 +2,19 @@ import { moduleFor, RenderingTestCase, strip, - classes, equalTokens, equalsElement, runTask, - runLoopSettled, - expectDeprecation, - testUnless, + defineSimpleModifier, } from 'internal-test-helpers'; -import { DEPRECATIONS } from '../../../../deprecations'; -import { action } from '@ember/object'; -import { run } from '@ember/runloop'; -import { DEBUG } from '@glimmer/env'; import { tracked } from '@ember/-internals/metal'; import { alias } from '@ember/object/computed'; -import { on } from '@ember/object/evented'; import Service, { service } from '@ember/service'; -import EmberObject, { set, get, computed, observer } from '@ember/object'; -import { A as emberA } from '@ember/array'; +import EmberObject, { set, computed } from '@ember/object'; +import { trackedArray } from '@ember/reactive/collections'; -import { Component, htmlSafe } from '../../utils/helpers'; +import { htmlSafe } from '../../utils/helpers'; import PositionalComponent from '../../utils/positional-component'; import GlimmerComponent from '@glimmer/component'; import { template } from '@ember/template-compiler/runtime'; @@ -49,3434 +41,1400 @@ moduleFor( this.assertHTML('hello'); } - ['@test it can have a custom id and it is not bound']() { + ['@test it renders passed named arguments']() { this.owner.register( 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{this.id}} {{this.elementId}}'), - class extends Component {} - ) + setComponentTemplate(precompileTemplate('{{@foo}}'), templateOnly()) ); - this.render('{{foo-bar id=this.customId}}', { - customId: 'bizz', + this.render('{{foo-bar foo=this.model.bar}}', { + model: { + bar: 'Hola', + }, }); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bizz bizz', - }); + this.assertText('Hola'); runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bizz bizz', - }); + this.assertText('Hola'); - runTask(() => set(this.context, 'customId', 'bar')); + runTask(() => this.context.set('model.bar', 'Hello')); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bar bizz', - }); + this.assertText('Hello'); - runTask(() => set(this.context, 'customId', 'bizz')); + runTask(() => this.context.set('model', { bar: 'Hola' })); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'bizz' }, - content: 'bizz bizz', - }); + this.assertText('Hola'); } - ['@test elementId cannot change'](assert) { - let component; - let FooBarComponent = class extends Component { - elementId = 'blahzorz'; - init() { - super.init(...arguments); - component = this; - } - }; - + ['@test it can render a basic component with a block']() { this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.elementId}}'), FooBarComponent) + setComponentTemplate(precompileTemplate('{{yield}} - In component'), templateOnly()) ); - this.render('{{foo-bar}}'); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'blahzorz' }, - content: 'blahzorz', - }); + this.render('{{#foo-bar}}hello{{/foo-bar}}'); - if (DEBUG) { - let willThrow = () => run(null, set, component, 'elementId', 'herpyderpy'); + this.assertHTML('hello - In component'); - assert.throws(willThrow, /Changing a view's elementId after creation is not allowed/); + runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { id: 'blahzorz' }, - content: 'blahzorz', - }); - } + this.assertHTML('hello - In component'); } - ['@test elementId is stable when other values change']() { - let changingArg = 'arbitrary value'; - let parentInstance; - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{quux-baz elementId="stable-id" changingArg=this.changingArg}}'), - class extends Component { - init() { - super.init(...arguments); - parentInstance = this; - } - changingArg = changingArg; - } - ) - ); - - this.owner.register( - 'component:quux-baz', - setComponentTemplate(precompileTemplate('{{this.changingArg}}'), class extends Component {}) - ); - - this.render('{{foo-bar}}'); - this.assertComponentElement(this.firstChild.firstChild, { - attrs: { id: 'stable-id' }, - content: 'arbitrary value', - }); + ['@test it renders the layout with the component instance as the context']() { + let instance; - changingArg = 'a different value'; - runTask(() => set(parentInstance, 'changingArg', changingArg)); - this.assertComponentElement(this.firstChild.firstChild, { - attrs: { id: 'stable-id' }, - content: changingArg, - }); - } + let FooBarComponent = class extends GlimmerComponent { + @tracked message = 'hello'; - ['@test can specify template with `layoutName` property']() { - let FooBarComponent = class extends Component { - elementId = 'blahzorz'; - layoutName = 'fizz-bar'; - init() { - super.init(...arguments); - this.local = 'hey'; + constructor(owner, args) { + super(owner, args); + instance = this; } }; - this.registerTemplate('fizz-bar', `FIZZ BAR {{this.local}}`); - - this.owner.register('component:foo-bar', FooBarComponent); + this.owner.register( + 'component:foo-bar', + setComponentTemplate(precompileTemplate('{{this.message}}'), FooBarComponent) + ); this.render('{{foo-bar}}'); - this.assertText('FIZZ BAR hey'); - } + this.assertHTML('hello'); - ['@test layout supports computed property']() { - let FooBarComponent = class extends Component { - elementId = 'blahzorz'; - @computed - get layout() { - return precompileTemplate('so much layout wat {{this.lulz}}'); - } - init() { - super.init(...arguments); - this.lulz = 'heyo'; - } - }; + runTask(() => this.rerender()); - this.owner.register('component:foo-bar', FooBarComponent); + this.assertHTML('hello'); - this.render('{{foo-bar}}'); + runTask(() => set(instance, 'message', 'goodbye')); - this.assertText('so much layout wat heyo'); - } + this.assertHTML('goodbye'); - ['@test passing undefined elementId results in a default elementId'](assert) { - let FooBarComponent = class extends Component { - tagName = 'h1'; - }; + runTask(() => set(instance, 'message', 'hello')); + + this.assertHTML('hello'); + } + ['@test it preserves the outer context when yielding']() { this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('something'), FooBarComponent) + setComponentTemplate(precompileTemplate('{{yield}}'), templateOnly()) ); - this.render('{{foo-bar id=this.somethingUndefined}}'); + this.render('{{#foo-bar}}{{this.message}}{{/foo-bar}}', { message: 'hello' }); - let foundId = this.$('h1').attr('id'); - assert.ok( - /^ember/.test(foundId), - 'Has a reasonable id attribute (found id=' + foundId + ').' - ); + this.assertHTML('hello'); runTask(() => this.rerender()); - let newFoundId = this.$('h1').attr('id'); - assert.ok( - /^ember/.test(newFoundId), - 'Has a reasonable id attribute (found id=' + newFoundId + ').' - ); - - assert.equal(foundId, newFoundId); - } + this.assertHTML('hello'); - ['@test id is an alias for elementId'](assert) { - let FooBarComponent = class extends Component { - tagName = 'h1'; - }; + runTask(() => set(this.context, 'message', 'goodbye')); - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('something'), FooBarComponent) - ); + this.assertHTML('goodbye'); - this.render('{{foo-bar id="custom-id"}}'); + runTask(() => set(this.context, 'message', 'hello')); - let foundId = this.$('h1').attr('id'); - assert.equal(foundId, 'custom-id'); + this.assertHTML('hello'); + } - runTask(() => this.rerender()); + ['@test it can yield a block param named for reserved words [GH#14096]']() { + let instance; - let newFoundId = this.$('h1').attr('id'); - assert.equal(newFoundId, 'custom-id'); + let FooBarComponent = class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + instance = this; + } - assert.equal(foundId, newFoundId); - } + @tracked name = 'foo-bar'; + }; - ['@test cannot pass both id and elementId at the same time']() { this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate(''), class extends Component {}) + setComponentTemplate(precompileTemplate('{{yield this}}'), FooBarComponent) ); - expectAssertion(() => { - this.render('{{foo-bar id="zomg" elementId="lol"}}'); - }, /You cannot invoke a component with both 'id' and 'elementId' at the same time./); - } + this.render('{{#foo-bar as |component|}}{{component.name}}{{/foo-bar}}'); - ['@test it can have a custom tagName']() { - let FooBarComponent = class extends Component { - tagName = 'foo-bar'; - }; + this.assertHTML('foo-bar'); - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); + this.assertStableRerender(); - this.render('{{foo-bar}}'); + runTask(() => set(instance, 'name', 'derp-qux')); - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); + this.assertHTML('derp-qux'); - runTask(() => this.rerender()); + runTask(() => set(instance, 'name', 'foo-bar')); - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); + this.assertHTML('foo-bar'); } - ['@test it can have a custom tagName set in the constructor']() { - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - this.tagName = 'foo-bar'; + ['@test it can yield internal and external properties positionally']() { + let instance; + + let FooBarComponent = class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + instance = this; } + + @tracked greeting = 'hello'; }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + setComponentTemplate( + precompileTemplate('{{yield this.greeting @greetee.firstName}}'), + FooBarComponent + ) ); - this.render('{{foo-bar}}'); + this.render( + '{{#foo-bar greetee=this.person as |greeting name|}}{{name}} {{this.person.lastName}}, {{greeting}}{{/foo-bar}}', + { + person: { + firstName: 'Joel', + lastName: 'Kang', + }, + } + ); - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); + this.assertHTML('Joel Kang, hello'); runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); - } + this.assertHTML('Joel Kang, hello'); - ['@test it can have a custom tagName from the invocation']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + runTask(() => + set(this.context, 'person', { + firstName: 'Dora', + lastName: 'the Explorer', + }) ); - this.render('{{foo-bar tagName="foo-bar"}}'); + this.assertHTML('Dora the Explorer, hello'); - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', - }); + runTask(() => set(instance, 'greeting', 'hola')); - runTask(() => this.rerender()); + this.assertHTML('Dora the Explorer, hola'); - this.assertComponentElement(this.firstChild, { - tagName: 'foo-bar', - content: 'hello', + runTask(() => { + set(instance, 'greeting', 'hello'); + set(this.context, 'person', { + firstName: 'Joel', + lastName: 'Kang', + }); }); - } - - ['@test tagName can not be a computed property']() { - let FooBarComponent = class extends Component { - @computed - get tagName() { - return 'foo-bar'; - } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); - expectAssertion(() => { - this.render('{{foo-bar}}'); - }, /You cannot use a computed property for the component's `tagName` \(<.+?>\)\./); + this.assertHTML('Joel Kang, hello'); } - ['@test class is applied before didInsertElement'](assert) { - let componentClass; - let FooBarComponent = class extends Component { - didInsertElement() { - componentClass = this.element.className; + ['@test #11519 - block param infinite loop']() { + let instance; + let FooBarComponent = class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + instance = this; } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); - - this.render('{{foo-bar class="foo-bar"}}'); - - assert.equal(componentClass, 'foo-bar ember-view'); - } - ['@test it can have custom classNames']() { - let FooBarComponent = class extends Component { - classNames = ['foo', 'bar']; + @tracked danger = 0; }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + setComponentTemplate( + precompileTemplate('{{this.danger}}{{yield this.danger}}'), + FooBarComponent + ) ); - this.render('{{foo-bar}}'); + // On initial render, create streams. The bug will not have manifested yet, but at this point + // we have created streams that create a circular invalidation. + this.render(`{{#foo-bar as |dangerBlockParam|}}{{/foo-bar}}`); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view foo bar') }, - content: 'hello', - }); + this.assertText('0'); + // Trigger a non-revalidating re-render. The yielded block will not be dirtied + // nor will block param streams, and thus no infinite loop will occur. runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view foo bar') }, - content: 'hello', - }); - } - - ['@test should not apply falsy class name']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends Component {}) - ); + this.assertText('0'); - this.render('{{foo-bar class=this.somethingFalsy}}', { - somethingFalsy: false, - }); + // Trigger a revalidation, which will cause an infinite loop without the fix + // in place. Note that we do not see the infinite loop is in testing mode, + // because a deprecation warning about re-renders is issued, which Ember + // treats as an exception. + runTask(() => set(instance, 'danger', 1)); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: 'ember-view' }, - content: 'hello', - }); + this.assertText('1'); - runTask(() => this.rerender()); + runTask(() => set(instance, 'danger', 0)); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: 'ember-view' }, - content: 'hello', - }); + this.assertText('0'); } - ['@test should update class using inline if, initially false, no alternate']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends Component {}) - ); - - this.render('{{foo-bar class=(if this.predicate "thing") }}', { - predicate: false, - }); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: 'ember-view' }, - content: 'hello', - }); - - runTask(() => set(this.context, 'predicate', true)); - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view thing') }, - content: 'hello', - }); - - runTask(() => set(this.context, 'predicate', false)); - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: 'ember-view' }, - content: 'hello', - }); - } + ['@test the component and its child components are destroyed'](assert) { + let destroyed = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 }; - ['@test should update class using inline if, initially true, no alternate']() { this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends Component {}) + setComponentTemplate( + precompileTemplate('{{@id}} {{yield}}'), + class extends GlimmerComponent { + willDestroy() { + super.willDestroy(); + destroyed[this.args.id]++; + } + } + ) ); - this.render('{{foo-bar class=(if this.predicate "thing") }}', { - predicate: true, - }); - - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view thing') }, - content: 'hello', - }); - - runTask(() => set(this.context, 'predicate', false)); - runTask(() => this.rerender()); + this.render( + strip` + {{#if this.cond1}} + {{#foo-bar id=1}} + {{#if this.cond2}} + {{#foo-bar id=2}}{{/foo-bar}} + {{#if this.cond3}} + {{#foo-bar id=3}} + {{#if this.cond4}} + {{#foo-bar id=4}} + {{#if this.cond5}} + {{#foo-bar id=5}}{{/foo-bar}} + {{#foo-bar id=6}}{{/foo-bar}} + {{#foo-bar id=7}}{{/foo-bar}} + {{/if}} + {{#foo-bar id=8}}{{/foo-bar}} + {{/foo-bar}} + {{/if}} + {{/foo-bar}} + {{/if}} + {{/if}} + {{/foo-bar}} + {{/if}}`, + { + cond1: true, + cond2: true, + cond3: true, + cond4: true, + cond5: true, + } + ); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: 'ember-view' }, - content: 'hello', - }); + this.assertText('1 2 3 4 5 6 7 8 '); - runTask(() => set(this.context, 'predicate', true)); runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view thing') }, - content: 'hello', + assert.deepEqual(destroyed, { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, }); - } - ['@test class property on components can be dynamic']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends Component {}) - ); + runTask(() => set(this.context, 'cond5', false)); - this.render('{{foo-bar class=(if this.fooBar "foo-bar")}}', { - fooBar: true, - }); + this.assertText('1 2 3 4 8 '); - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view foo-bar') }, + assert.deepEqual(destroyed, { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 1, + 6: 1, + 7: 1, + 8: 0, }); - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view foo-bar') }, + runTask(() => { + set(this.context, 'cond3', false); + set(this.context, 'cond5', true); + set(this.context, 'cond4', false); }); - runTask(() => set(this.context, 'fooBar', false)); - - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view') }, + assert.deepEqual(destroyed, { + 1: 0, + 2: 0, + 3: 1, + 4: 1, + 5: 1, + 6: 1, + 7: 1, + 8: 1, }); - runTask(() => set(this.context, 'fooBar', true)); + runTask(() => { + set(this.context, 'cond2', false); + set(this.context, 'cond1', false); + }); - this.assertComponentElement(this.firstChild, { - content: 'hello', - attrs: { class: classes('ember-view foo-bar') }, + assert.deepEqual(destroyed, { + 1: 1, + 2: 1, + 3: 1, + 4: 1, + 5: 1, + 6: 1, + 7: 1, + 8: 1, }); } - ['@test it can have custom classNames from constructor']() { - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - this.classNames = this.classNames.slice(); - this.classNames.push('foo', 'bar', `outside-${this.get('extraClass')}`); + ['@test should escape HTML in normal mustaches']() { + let component; + let FooBarComponent = class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + component = this; } + + @tracked output = 'you need to be more bold'; }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + setComponentTemplate(precompileTemplate('{{this.output}}'), FooBarComponent) ); - this.render('{{foo-bar extraClass="baz"}}'); + this.render('{{foo-bar}}'); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view foo bar outside-baz') }, - content: 'hello', - }); + this.assertText('you need to be more bold'); runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { - tagName: 'div', - attrs: { class: classes('ember-view foo bar outside-baz') }, - content: 'hello', - }); - } - - ['@test it can set custom classNames from the invocation']() { - let FooBarComponent = class extends Component { - classNames = ['foo']; - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); - - this.render(strip` - {{foo-bar class="bar baz"}} - {{foo-bar classNames="bar baz"}} - {{foo-bar}} - `); + this.assertText('you need to be more bold'); - this.assertComponentElement(this.nthChild(0), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(1), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(2), { - tagName: 'div', - attrs: { class: classes('ember-view foo') }, - content: 'hello', - }); + runTask(() => set(component, 'output', 'you are so super')); - runTask(() => this.rerender()); + this.assertText('you are so super'); - this.assertComponentElement(this.nthChild(0), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(1), { - tagName: 'div', - attrs: { class: classes('ember-view foo bar baz') }, - content: 'hello', - }); - this.assertComponentElement(this.nthChild(2), { - tagName: 'div', - attrs: { class: classes('ember-view foo') }, - content: 'hello', - }); + runTask(() => set(component, 'output', 'you need to be more bold')); } - ['@test it has an element']() { - let instance; - - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - instance = this; + ['@test should not escape HTML in triple mustaches']() { + let expectedHtmlBold = 'you need to be more bold'; + let expectedHtmlItalic = 'you are so super'; + let component; + let FooBarComponent = class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + component = this; } + + @tracked output = expectedHtmlBold; }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + setComponentTemplate(precompileTemplate('{{{this.output}}}'), FooBarComponent) ); this.render('{{foo-bar}}'); - let element1 = instance.element; - - this.assertComponentElement(element1, { content: 'hello' }); + equalTokens(this.element, expectedHtmlBold); runTask(() => this.rerender()); - let element2 = instance.element; + equalTokens(this.element, expectedHtmlBold); + + runTask(() => set(component, 'output', expectedHtmlItalic)); + + equalTokens(this.element, expectedHtmlItalic); - this.assertComponentElement(element2, { content: 'hello' }); + runTask(() => set(component, 'output', expectedHtmlBold)); - this.assertSameNode(element2, element1); + equalTokens(this.element, expectedHtmlBold); } - ['@test an empty component does not have childNodes'](assert) { - let fooBarInstance; - let FooBarComponent = class extends Component { - tagName = 'input'; - init() { - super.init(...arguments); - fooBarInstance = this; + ['@test should not escape HTML if string is a htmlSafe']() { + let expectedHtmlBold = 'you need to be more bold'; + let expectedHtmlItalic = 'you are so super'; + let component; + let FooBarComponent = class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + component = this; } + + @tracked output = htmlSafe(expectedHtmlBold); }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate(''), FooBarComponent) + setComponentTemplate(precompileTemplate('{{this.output}}'), FooBarComponent) ); this.render('{{foo-bar}}'); - this.assertComponentElement(this.firstChild, { tagName: 'input' }); - - assert.strictEqual(fooBarInstance.element.childNodes.length, 0); + equalTokens(this.element, expectedHtmlBold); runTask(() => this.rerender()); - this.assertComponentElement(this.firstChild, { tagName: 'input' }); + equalTokens(this.element, expectedHtmlBold); - assert.strictEqual(fooBarInstance.element.childNodes.length, 0); - } + runTask(() => set(component, 'output', htmlSafe(expectedHtmlItalic))); + + equalTokens(this.element, expectedHtmlItalic); - ['@test it has the right parentView and childViews'](assert) { - let fooBarInstance, fooBarBazInstance; + runTask(() => set(component, 'output', htmlSafe(expectedHtmlBold))); - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - fooBarInstance = this; - } - }; + equalTokens(this.element, expectedHtmlBold); + } + + ['@test can use isStream property without conflict (#13271)']() { + let component; + let FooBarComponent = class extends GlimmerComponent { + @tracked isStream = true; - let FooBarBazComponent = class extends Component { - init() { - super.init(...arguments); - fooBarBazInstance = this; + constructor(owner, args) { + super(owner, args); + component = this; } }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('foo-bar {{foo-bar-baz}}'), FooBarComponent) - ); - this.owner.register( - 'component:foo-bar-baz', - setComponentTemplate(precompileTemplate('foo-bar-baz'), FooBarBazComponent) + template( + strip` + {{#if this.isStream}} + true + {{else}} + false + {{/if}} + `, + { component: FooBarComponent, strictMode: false } + ) ); this.render('{{foo-bar}}'); - this.assertText('foo-bar foo-bar-baz'); - assert.equal(fooBarInstance.parentView, this.component); - assert.equal(fooBarBazInstance.parentView, fooBarInstance); - - assert.deepEqual(this.component.childViews, [fooBarInstance]); - assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + this.assertHTML('true'); runTask(() => this.rerender()); - this.assertText('foo-bar foo-bar-baz'); - assert.equal(fooBarInstance.parentView, this.component); - assert.equal(fooBarBazInstance.parentView, fooBarInstance); + this.assertHTML('true'); - assert.deepEqual(this.component.childViews, [fooBarInstance]); - assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); - } + runTask(() => set(component, 'isStream', false)); - ['@test it renders passed named arguments']() { + this.assertHTML('false'); + + runTask(() => set(component, 'isStream', true)); + + this.assertHTML('true'); + } + ['@test lookup of component takes priority over property']() { this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{@foo}}'), templateOnly()) + 'component:some-component', + setComponentTemplate(precompileTemplate('some-component'), templateOnly()) ); - this.render('{{foo-bar foo=this.model.bar}}', { - model: { - bar: 'Hola', - }, + this.render('{{this.some-prop}} {{some-component}}', { + 'some-component': 'not-some-component', + 'some-prop': 'some-prop', }); - this.assertText('Hola'); + this.assertText('some-prop some-component'); runTask(() => this.rerender()); - this.assertText('Hola'); - - runTask(() => this.context.set('model.bar', 'Hello')); - - this.assertText('Hello'); - - runTask(() => this.context.set('model', { bar: 'Hola' })); - - this.assertText('Hola'); + this.assertText('some-prop some-component'); } - ['@test it reflects named arguments as properties']() { + ['@test component without dash is looked up']() { this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.foo}}'), class extends Component {}) + 'component:somecomponent', + setComponentTemplate(precompileTemplate('somecomponent'), templateOnly()) ); - this.render('{{foo-bar foo=this.model.bar}}', { - model: { - bar: 'Hola', - }, + this.render('{{somecomponent}}', { + somecomponent: 'notsomecomponent', }); - this.assertText('Hola'); - - runTask(() => this.rerender()); + this.assertText('somecomponent'); - this.assertText('Hola'); + this.assertStableRerender(); - runTask(() => this.context.set('model.bar', 'Hello')); + runTask(() => this.context.set('somecomponent', 'not not notsomecomponent')); - this.assertText('Hello'); + this.assertText('somecomponent'); - runTask(() => this.context.set('model', { bar: 'Hola' })); + runTask(() => this.context.set('somecomponent', 'notsomecomponent')); - this.assertText('Hola'); + this.assertText('somecomponent'); } - ['@test it can render a basic component with a block']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{yield}} - In component'), templateOnly()) - ); - - this.render('{{#foo-bar}}hello{{/foo-bar}}'); - - this.assertHTML('hello - In component'); - - runTask(() => this.rerender()); - - this.assertHTML('hello - In component'); - } - - ['@test it renders the layout with the component instance as the context']() { - let instance; - - let FooBarComponent = class extends GlimmerComponent { - @tracked message = 'hello'; - - constructor(owner, args) { - super(owner, args); - instance = this; - } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.message}}'), FooBarComponent) - ); - - this.render('{{foo-bar}}'); - - this.assertHTML('hello'); - - runTask(() => this.rerender()); - - this.assertHTML('hello'); - - runTask(() => set(instance, 'message', 'goodbye')); - - this.assertHTML('goodbye'); - - runTask(() => set(instance, 'message', 'hello')); - - this.assertHTML('hello'); - } - - ['@test it preserves the outer context when yielding']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{yield}}'), templateOnly()) - ); - - this.render('{{#foo-bar}}{{this.message}}{{/foo-bar}}', { message: 'hello' }); - - this.assertHTML('hello'); - - runTask(() => this.rerender()); - - this.assertHTML('hello'); - - runTask(() => set(this.context, 'message', 'goodbye')); - - this.assertHTML('goodbye'); - - runTask(() => set(this.context, 'message', 'hello')); - - this.assertHTML('hello'); - } - - ['@test it can yield a block param named for reserved words [GH#14096]']() { - let instance; - - let FooBarComponent = class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - instance = this; - } - - @tracked name = 'foo-bar'; - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{yield this}}'), FooBarComponent) - ); - - this.render('{{#foo-bar as |component|}}{{component.name}}{{/foo-bar}}'); - - this.assertHTML('foo-bar'); - - this.assertStableRerender(); - - runTask(() => set(instance, 'name', 'derp-qux')); - - this.assertHTML('derp-qux'); - - runTask(() => set(instance, 'name', 'foo-bar')); - - this.assertHTML('foo-bar'); + ['@test non-block with properties access via attrs is asserted against']() { + expectAssertion(() => { + this.owner.register( + 'component:non-block', + template('In layout - someProp: {{attrs.someProp}}', { + component: templateOnly(), + strictMode: false, + }) + ); + }, 'Using {{attrs}} to reference named arguments is not supported. {{attrs.someProp}} should be updated to {{@someProp}}. (L1:C24) '); } - ['@test it can yield internal and external properties positionally']() { - let instance; - - let FooBarComponent = class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - instance = this; - } - - @tracked greeting = 'hello'; - }; - + ['@test non-block with named argument']() { this.owner.register( - 'component:foo-bar', + 'component:non-block', setComponentTemplate( - precompileTemplate('{{yield this.greeting @greetee.firstName}}'), - FooBarComponent + precompileTemplate('In layout - someProp: {{@someProp}}'), + templateOnly() ) ); - this.render( - '{{#foo-bar greetee=this.person as |greeting name|}}{{name}} {{this.person.lastName}}, {{greeting}}{{/foo-bar}}', - { - person: { - firstName: 'Joel', - lastName: 'Kang', - }, - } - ); - - this.assertHTML('Joel Kang, hello'); - - runTask(() => this.rerender()); - - this.assertHTML('Joel Kang, hello'); - - runTask(() => - set(this.context, 'person', { - firstName: 'Dora', - lastName: 'the Explorer', - }) - ); - - this.assertHTML('Dora the Explorer, hello'); - - runTask(() => set(instance, 'greeting', 'hola')); - - this.assertHTML('Dora the Explorer, hola'); - - runTask(() => { - set(instance, 'greeting', 'hello'); - set(this.context, 'person', { - firstName: 'Joel', - lastName: 'Kang', - }); + this.render('{{non-block someProp=this.prop}}', { + prop: 'something here', }); - this.assertHTML('Joel Kang, hello'); - } - - ['@test #11519 - block param infinite loop']() { - let instance; - let FooBarComponent = class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - instance = this; - } - - @tracked danger = 0; - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{this.danger}}{{yield this.danger}}'), - FooBarComponent - ) - ); - - // On initial render, create streams. The bug will not have manifested yet, but at this point - // we have created streams that create a circular invalidation. - this.render(`{{#foo-bar as |dangerBlockParam|}}{{/foo-bar}}`); - - this.assertText('0'); + this.assertText('In layout - someProp: something here'); - // Trigger a non-revalidating re-render. The yielded block will not be dirtied - // nor will block param streams, and thus no infinite loop will occur. runTask(() => this.rerender()); - this.assertText('0'); - - // Trigger a revalidation, which will cause an infinite loop without the fix - // in place. Note that we do not see the infinite loop is in testing mode, - // because a deprecation warning about re-renders is issued, which Ember - // treats as an exception. - runTask(() => set(instance, 'danger', 1)); - - this.assertText('1'); - - runTask(() => set(instance, 'danger', 0)); - - this.assertText('0'); - } - - ['@test the component and its child components are destroyed'](assert) { - let destroyed = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{@id}} {{yield}}'), - class extends GlimmerComponent { - willDestroy() { - super.willDestroy(); - destroyed[this.args.id]++; - } - } - ) - ); - - this.render( - strip` - {{#if this.cond1}} - {{#foo-bar id=1}} - {{#if this.cond2}} - {{#foo-bar id=2}}{{/foo-bar}} - {{#if this.cond3}} - {{#foo-bar id=3}} - {{#if this.cond4}} - {{#foo-bar id=4}} - {{#if this.cond5}} - {{#foo-bar id=5}}{{/foo-bar}} - {{#foo-bar id=6}}{{/foo-bar}} - {{#foo-bar id=7}}{{/foo-bar}} - {{/if}} - {{#foo-bar id=8}}{{/foo-bar}} - {{/foo-bar}} - {{/if}} - {{/foo-bar}} - {{/if}} - {{/if}} - {{/foo-bar}} - {{/if}}`, - { - cond1: true, - cond2: true, - cond3: true, - cond4: true, - cond5: true, - } - ); + this.assertText('In layout - someProp: something here'); - this.assertText('1 2 3 4 5 6 7 8 '); - - runTask(() => this.rerender()); - - assert.deepEqual(destroyed, { - 1: 0, - 2: 0, - 3: 0, - 4: 0, - 5: 0, - 6: 0, - 7: 0, - 8: 0, - }); - - runTask(() => set(this.context, 'cond5', false)); - - this.assertText('1 2 3 4 8 '); - - assert.deepEqual(destroyed, { - 1: 0, - 2: 0, - 3: 0, - 4: 0, - 5: 1, - 6: 1, - 7: 1, - 8: 0, - }); - - runTask(() => { - set(this.context, 'cond3', false); - set(this.context, 'cond5', true); - set(this.context, 'cond4', false); - }); - - assert.deepEqual(destroyed, { - 1: 0, - 2: 0, - 3: 1, - 4: 1, - 5: 1, - 6: 1, - 7: 1, - 8: 1, - }); - - runTask(() => { - set(this.context, 'cond2', false); - set(this.context, 'cond1', false); - }); - - assert.deepEqual(destroyed, { - 1: 1, - 2: 1, - 3: 1, - 4: 1, - 5: 1, - 6: 1, - 7: 1, - 8: 1, - }); - } - - ['@test should escape HTML in normal mustaches']() { - let component; - let FooBarComponent = class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - component = this; - } - - @tracked output = 'you need to be more bold'; - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.output}}'), FooBarComponent) - ); - - this.render('{{foo-bar}}'); - - this.assertText('you need to be more bold'); - - runTask(() => this.rerender()); - - this.assertText('you need to be more bold'); - - runTask(() => set(component, 'output', 'you are so super')); - - this.assertText('you are so super'); - - runTask(() => set(component, 'output', 'you need to be more bold')); - } - - ['@test should not escape HTML in triple mustaches']() { - let expectedHtmlBold = 'you need to be more bold'; - let expectedHtmlItalic = 'you are so super'; - let component; - let FooBarComponent = class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - component = this; - } - - @tracked output = expectedHtmlBold; - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{{this.output}}}'), FooBarComponent) - ); - - this.render('{{foo-bar}}'); - - equalTokens(this.element, expectedHtmlBold); - - runTask(() => this.rerender()); - - equalTokens(this.element, expectedHtmlBold); - - runTask(() => set(component, 'output', expectedHtmlItalic)); - - equalTokens(this.element, expectedHtmlItalic); - - runTask(() => set(component, 'output', expectedHtmlBold)); - - equalTokens(this.element, expectedHtmlBold); - } - - ['@test should not escape HTML if string is a htmlSafe']() { - let expectedHtmlBold = 'you need to be more bold'; - let expectedHtmlItalic = 'you are so super'; - let component; - let FooBarComponent = class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - component = this; - } - - @tracked output = htmlSafe(expectedHtmlBold); - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.output}}'), FooBarComponent) - ); - - this.render('{{foo-bar}}'); - - equalTokens(this.element, expectedHtmlBold); - - runTask(() => this.rerender()); - - equalTokens(this.element, expectedHtmlBold); - - runTask(() => set(component, 'output', htmlSafe(expectedHtmlItalic))); - - equalTokens(this.element, expectedHtmlItalic); - - runTask(() => set(component, 'output', htmlSafe(expectedHtmlBold))); - - equalTokens(this.element, expectedHtmlBold); - } - - ['@test late bound layouts return the same definition'](assert) { - let templateIds = []; - - // This is testing the scenario where you import a template and - // set it to the layout property: - // - // import Component from '@ember/component'; - // import layout from './template'; - // - // export default Component.extend({ - // layout - // }); - let hello = precompileTemplate('Hello'); - let bye = precompileTemplate('Bye'); - - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - this.layout = this.cond ? hello : bye; - templateIds.push(this.layout.id); - } - }; - - this.owner.register('component:foo-bar', FooBarComponent); - - this.render( - '{{foo-bar cond=true}}{{foo-bar cond=false}}{{foo-bar cond=true}}{{foo-bar cond=false}}' - ); - - let [t1, t2, t3, t4] = templateIds; - assert.equal(t1, t3); - assert.equal(t2, t4); - } - - ['@test can use isStream property without conflict (#13271)']() { - let component; - let FooBarComponent = class extends GlimmerComponent { - @tracked isStream = true; - - constructor(owner, args) { - super(owner, args); - component = this; - } - }; - - this.owner.register( - 'component:foo-bar', - template( - strip` - {{#if this.isStream}} - true - {{else}} - false - {{/if}} - `, - { component: FooBarComponent, strictMode: false } - ) - ); - - this.render('{{foo-bar}}'); - - this.assertHTML('true'); - - runTask(() => this.rerender()); - - this.assertHTML('true'); - - runTask(() => set(component, 'isStream', false)); - - this.assertHTML('false'); - - runTask(() => set(component, 'isStream', true)); - - this.assertHTML('true'); - } - ['@test lookup of component takes priority over property']() { - this.owner.register( - 'component:some-component', - setComponentTemplate(precompileTemplate('some-component'), templateOnly()) - ); - - this.render('{{this.some-prop}} {{some-component}}', { - 'some-component': 'not-some-component', - 'some-prop': 'some-prop', - }); - - this.assertText('some-prop some-component'); - - runTask(() => this.rerender()); - - this.assertText('some-prop some-component'); - } - - ['@test component without dash is looked up']() { - this.owner.register( - 'component:somecomponent', - setComponentTemplate(precompileTemplate('somecomponent'), templateOnly()) - ); - - this.render('{{somecomponent}}', { - somecomponent: 'notsomecomponent', - }); - - this.assertText('somecomponent'); - - this.assertStableRerender(); - - runTask(() => this.context.set('somecomponent', 'not not notsomecomponent')); - - this.assertText('somecomponent'); - - runTask(() => this.context.set('somecomponent', 'notsomecomponent')); - - this.assertText('somecomponent'); - } - - ['@test non-block with properties access via attrs is asserted against']() { - expectAssertion(() => { - this.owner.register( - 'component:non-block', - template('In layout - someProp: {{attrs.someProp}}', { - component: templateOnly(), - strictMode: false, - }) - ); - }, 'Using {{attrs}} to reference named arguments is not supported. {{attrs.someProp}} should be updated to {{@someProp}}. (L1:C24) '); - } - - // Perhaps change this test to `{{this.attrs.someProp.value}}` when removing the deprecation? - ['@test non-block with properties on this.attrs']() { - expectDeprecation(() => { - this.owner.register( - 'component:non-block', - template('In layout - someProp: {{this.attrs.someProp}}', { - component: class extends Component {}, - strictMode: false, - }) - ); - }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs.someProp}} should be updated to {{@someProp}}./); - - this.render('{{non-block someProp=this.prop}}', { - prop: 'something here', - }); - - this.assertText('In layout - someProp: something here'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: something here'); - - runTask(() => this.context.set('prop', 'other thing there')); - - this.assertText('In layout - someProp: other thing there'); - - runTask(() => this.context.set('prop', 'something here')); - - this.assertText('In layout - someProp: something here'); - } - - ['@test non-block with named argument']() { - this.owner.register( - 'component:non-block', - setComponentTemplate( - precompileTemplate('In layout - someProp: {{@someProp}}'), - templateOnly() - ) - ); - - this.render('{{non-block someProp=this.prop}}', { - prop: 'something here', - }); - - this.assertText('In layout - someProp: something here'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: something here'); - - runTask(() => this.context.set('prop', 'other thing there')); - - this.assertText('In layout - someProp: other thing there'); - - runTask(() => this.context.set('prop', 'something here')); - - this.assertText('In layout - someProp: something here'); - } - - ['@test non-block with properties overridden in init']() { - let instance; - this.owner.register( - 'component:non-block', - setComponentTemplate( - precompileTemplate('In layout - someProp: {{this.someProp}}'), - class extends Component { - init() { - super.init(...arguments); - instance = this; - this.someProp = 'value set in instance'; - } - } - ) - ); - - this.render('{{non-block someProp=this.prop}}', { - prop: 'something passed when invoked', - }); - - this.assertText('In layout - someProp: value set in instance'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: value set in instance'); - - runTask(() => this.context.set('prop', 'updated something passed when invoked')); - - this.assertText('In layout - someProp: updated something passed when invoked'); - - runTask(() => instance.set('someProp', 'update value set in instance')); - - this.assertText('In layout - someProp: update value set in instance'); - - runTask(() => this.context.set('prop', 'something passed when invoked')); - runTask(() => instance.set('someProp', 'value set in instance')); - - this.assertText('In layout - someProp: value set in instance'); - } - - ['@test rerendering component with attrs from parent'](assert) { - let willUpdateCount = 0; - let didReceiveAttrsCount = 0; - - function expectHooks({ willUpdate, didReceiveAttrs }, callback) { - willUpdateCount = 0; - didReceiveAttrsCount = 0; - - callback(); - - if (willUpdate) { - assert.strictEqual(willUpdateCount, 1, 'The willUpdate hook was fired'); - } else { - assert.strictEqual(willUpdateCount, 0, 'The willUpdate hook was not fired'); - } - - if (didReceiveAttrs) { - assert.strictEqual(didReceiveAttrsCount, 1, 'The didReceiveAttrs hook was fired'); - } else { - assert.strictEqual(didReceiveAttrsCount, 0, 'The didReceiveAttrs hook was not fired'); - } - } - - this.owner.register( - 'component:non-block', - setComponentTemplate( - precompileTemplate('In layout - someProp: {{this.someProp}}'), - class extends Component { - didReceiveAttrs() { - didReceiveAttrsCount++; - } - - willUpdate() { - willUpdateCount++; - } - } - ) - ); - - expectHooks({ willUpdate: false, didReceiveAttrs: true }, () => { - this.render('{{non-block someProp=this.someProp}}', { - someProp: 'wycats', - }); - }); - - this.assertText('In layout - someProp: wycats'); - - // Note: Hooks are not fired in Glimmer for idempotent re-renders - expectHooks({ willUpdate: false, didReceiveAttrs: false }, () => { - runTask(() => this.rerender()); - }); - - this.assertText('In layout - someProp: wycats'); - - expectHooks({ willUpdate: true, didReceiveAttrs: true }, () => { - runTask(() => this.context.set('someProp', 'tomdale')); - }); - - this.assertText('In layout - someProp: tomdale'); - - // Note: Hooks are not fired in Glimmer for idempotent re-renders - expectHooks({ willUpdate: false, didReceiveAttrs: false }, () => { - runTask(() => this.rerender()); - }); - - this.assertText('In layout - someProp: tomdale'); - - expectHooks({ willUpdate: true, didReceiveAttrs: true }, () => { - runTask(() => this.context.set('someProp', 'wycats')); - }); - - this.assertText('In layout - someProp: wycats'); - } - - ['@test setting a value for a computed property then later getting the value for that property works']( - assert - ) { - let componentInstance = null; - - this.owner.register( - 'component:non-block', - setComponentTemplate( - precompileTemplate(''), - class extends Component { - counter = computed({ - set(key, value) { - return value; - }, - }); - - init() { - super.init(...arguments); - componentInstance = this; - } - - @action - myClick() { - let currentCounter = this.get('counter'); - - assert.equal(currentCounter, 0, 'the current `counter` value is correct'); - - let newCounter = currentCounter + 1; - this.set('counter', newCounter); - - assert.equal( - this.get('counter'), - newCounter, - "getting the newly set `counter` property works; it's equal to the value we just set and not `undefined`" - ); - } - } - ) - ); - - this.render(`{{non-block counter=this.counter}}`, { - counter: 0, - }); - - runTask(() => this.$('button').click()); - - assert.equal( - componentInstance.get('counter'), - 1, - '`counter` incremented on click on the component and is not `undefined`' - ); - } - - // Perhaps change this test to `{{this.attrs.foo.value}}` when removing the deprecation? - ['@test this.attrs.foo === @foo === foo']() { - expectDeprecation(() => { - this.owner.register( - 'component:foo-bar', - template( - strip` - Args: {{this.attrs.value}} | {{@value}} | {{this.value}} - {{#each this.attrs.items as |item|}} - {{item}} - {{/each}} - {{#each @items as |item|}} - {{item}} - {{/each}} - {{#each this.items as |item|}} - {{item}} - {{/each}} - `, - { component: class extends Component {}, strictMode: false } - ) - ); - }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs..+}} should be updated to {{@.+}}./); - - this.render('{{foo-bar value=this.model.value items=this.model.items}}', { - model: { - value: 'wat', - items: [1, 2, 3], - }, - }); - - this.assertStableRerender(); - - runTask(() => { - this.context.set('model.value', 'lul'); - this.context.set('model.items', [1]); - }); - - this.assertText(strip`Args: lul | lul | lul111`); - - runTask(() => this.context.set('model', { value: 'wat', items: [1, 2, 3] })); - - this.assertText('Args: wat | wat | wat123123123'); - } - - ['@test non-block with properties on self']() { - this.owner.register( - 'component:non-block', - setComponentTemplate( - precompileTemplate('In layout - someProp: {{this.someProp}}'), - class extends Component {} - ) - ); - - this.render('{{non-block someProp=this.prop}}', { - prop: 'something here', - }); - - this.assertText('In layout - someProp: something here'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: something here'); - - runTask(() => this.context.set('prop', 'something else')); - - this.assertText('In layout - someProp: something else'); - - runTask(() => this.context.set('prop', 'something here')); - - this.assertText('In layout - someProp: something here'); - } - - ['@test block with properties on self']() { - this.owner.register( - 'component:with-block', - setComponentTemplate( - precompileTemplate('In layout - someProp: {{this.someProp}} - {{yield}}'), - class extends Component {} - ) - ); - - this.render( - strip` - {{#with-block someProp=this.prop}} - In template - {{/with-block}}`, - { - prop: 'something here', - } - ); - - this.assertText('In layout - someProp: something here - In template'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: something here - In template'); - - runTask(() => this.context.set('prop', 'something else')); - - this.assertText('In layout - someProp: something else - In template'); - - runTask(() => this.context.set('prop', 'something here')); - - this.assertText('In layout - someProp: something here - In template'); - } - - ['@test block with properties on attrs is asserted against']() { - expectAssertion(() => { - this.owner.register( - 'component:with-block', - template('In layout - someProp: {{attrs.someProp}} - {{yield}}', { - component: templateOnly(), - strictMode: false, - }) - ); - }, 'Using {{attrs}} to reference named arguments is not supported. {{attrs.someProp}} should be updated to {{@someProp}}. (L1:C24) '); - } - - // Perhaps change this test to `{{this.attrs.someProp.value}}` when removing the deprecation? - ['@test block with properties on this.attrs']() { - expectDeprecation(() => { - this.owner.register( - 'component:with-block', - template('In layout - someProp: {{this.attrs.someProp}} - {{yield}}', { - component: class extends Component {}, - strictMode: false, - }) - ); - }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs.someProp}} should be updated to {{@someProp}}./); - - this.render( - strip` - {{#with-block someProp=this.prop}} - In template - {{/with-block}}`, - { - prop: 'something here', - } - ); - - this.assertText('In layout - someProp: something here - In template'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: something here - In template'); - - runTask(() => this.context.set('prop', 'something else')); - - this.assertText('In layout - someProp: something else - In template'); - - runTask(() => this.context.set('prop', 'something here')); - - this.assertText('In layout - someProp: something here - In template'); - } - - ['@test block with named argument']() { - this.owner.register( - 'component:with-block', - setComponentTemplate( - precompileTemplate('In layout - someProp: {{@someProp}} - {{yield}}'), - templateOnly() - ) - ); - - this.render( - strip` - {{#with-block someProp=this.prop}} - In template - {{/with-block}}`, - { - prop: 'something here', - } - ); - - this.assertText('In layout - someProp: something here - In template'); - - runTask(() => this.rerender()); - - this.assertText('In layout - someProp: something here - In template'); - - runTask(() => this.context.set('prop', 'something else')); - - this.assertText('In layout - someProp: something else - In template'); - - runTask(() => this.context.set('prop', 'something here')); - - this.assertText('In layout - someProp: something here - In template'); - } - - ['@test static arbitrary number of positional parameters'](assert) { - this.owner.register( - 'component:sample-component', - template( - strip` - {{#each this.names as |name|}} - {{name}} - {{/each}} - `, - { - component: class extends PositionalComponent { - static positionalParams = 'names'; - }, - strictMode: false, - } - ) - ); - - this.render(strip` -
    {{sample-component "Foo" 4 "Bar"}}
    -
    {{sample-component "Foo" 4 "Bar" 5 "Baz"}}
    `); - - assert.equal(this.$('#args-3').text(), 'Foo4Bar'); - assert.equal(this.$('#args-5').text(), 'Foo4Bar5Baz'); - - runTask(() => this.rerender()); - - assert.equal(this.$('#args-3').text(), 'Foo4Bar'); - assert.equal(this.$('#args-5').text(), 'Foo4Bar5Baz'); - } - - ['@test arbitrary positional parameter conflict with hash parameter is reported']() { - this.owner.register( - 'component:sample-component', - template( - strip` - {{#each this.names as |name|}} - {{name}} - {{/each}} - `, - { - component: class extends Component { - static positionalParams = 'names'; - }, - strictMode: false, - } - ) - ); - - expectAssertion(() => { - this.render(`{{sample-component "Foo" 4 "Bar" names=this.numbers id="args-3"}}`, { - numbers: [1, 2, 3], - }); - }, 'You cannot specify positional parameters and the hash argument `names`.'); - } - - ['@test can use hash parameter instead of arbitrary positional param [GH #12444]']() { - this.owner.register( - 'component:sample-component', - template( - strip` - {{#each this.names as |name|}} - {{name}} - {{/each}} - `, - { - component: class extends Component { - static positionalParams = 'names'; - }, - strictMode: false, - } - ) - ); - - this.render('{{sample-component names=this.things}}', { - things: emberA(['Foo', 4, 'Bar']), - }); - - this.assertText('Foo4Bar'); - - runTask(() => this.rerender()); - - this.assertText('Foo4Bar'); - - runTask(() => this.context.get('things').pushObject(5)); - - this.assertText('Foo4Bar5'); - - runTask(() => this.context.get('things').shiftObject()); - - this.assertText('4Bar5'); - - runTask(() => this.context.get('things').clear()); - - this.assertText(''); - - runTask(() => this.context.set('things', emberA(['Foo', 4, 'Bar']))); - - this.assertText('Foo4Bar'); - } - - ['@test can use hash parameter instead of positional param'](assert) { - this.owner.register( - 'component:sample-component', - setComponentTemplate( - precompileTemplate('{{this.first}} - {{this.second}}'), - class extends Component { - static positionalParams = ['first', 'second']; - } - ) - ); - - // TODO: Fix when id is implemented - this.render(strip` - {{sample-component "one" "two" elementId="two-positional"}} - {{sample-component "one" second="two" elementId="one-positional"}} - {{sample-component first="one" second="two" elementId="no-positional"}}`); - - assert.equal(this.$('#two-positional').text(), 'one - two'); - assert.equal(this.$('#one-positional').text(), 'one - two'); - assert.equal(this.$('#no-positional').text(), 'one - two'); - - runTask(() => this.rerender()); - - assert.equal(this.$('#two-positional').text(), 'one - two'); - assert.equal(this.$('#one-positional').text(), 'one - two'); - assert.equal(this.$('#no-positional').text(), 'one - two'); - } - - ['@test dynamic arbitrary number of positional parameters']() { - this.owner.register( - 'component:sample-component', - template( - strip` - {{#each this.n as |name|}} - {{name}} - {{/each}} - `, - { - component: class extends PositionalComponent { - static positionalParams = 'n'; - }, - strictMode: false, - } - ) - ); - - this.render(`{{sample-component this.user1 this.user2}}`, { - user1: 'Foo', - user2: 4, - }); - - this.assertText('Foo4'); - - runTask(() => this.rerender()); - - this.assertText('Foo4'); - - runTask(() => this.context.set('user1', 'Bar')); - - this.assertText('Bar4'); - - runTask(() => this.context.set('user2', '5')); - - this.assertText('Bar5'); - - runTask(() => { - this.context.set('user1', 'Foo'); - this.context.set('user2', 4); - }); - - this.assertText('Foo4'); - } - - ['@test with ariaRole specified']() { - this.owner.register( - 'component:aria-test', - setComponentTemplate(precompileTemplate('Here!'), class extends Component {}) - ); - - this.render('{{aria-test ariaRole=this.role}}', { - role: 'main', - }); - - this.assertComponentElement(this.firstChild, { attrs: { role: 'main' } }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { attrs: { role: 'main' } }); - - runTask(() => this.context.set('role', 'input')); - - this.assertComponentElement(this.firstChild, { - attrs: { role: 'input' }, - }); - - runTask(() => this.context.set('role', 'main')); - - this.assertComponentElement(this.firstChild, { attrs: { role: 'main' } }); - } - - ['@test with ariaRole defined but initially falsey GH#16379']() { - this.owner.register( - 'component:aria-test', - setComponentTemplate(precompileTemplate('Here!'), class extends Component {}) - ); - - this.render('{{aria-test ariaRole=this.role}}', { - role: undefined, - }); - - this.assertComponentElement(this.firstChild, { attrs: {} }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { attrs: {} }); - - runTask(() => this.context.set('role', 'input')); - - this.assertComponentElement(this.firstChild, { - attrs: { role: 'input' }, - }); - - runTask(() => this.context.set('role', undefined)); - - this.assertComponentElement(this.firstChild, { attrs: {} }); - } - - ['@test without ariaRole defined initially']() { - // we are using the ability to lazily add a role as a sign that we are - // doing extra work - let instance; - this.owner.register( - 'component:aria-test', - setComponentTemplate( - precompileTemplate('Here!'), - class extends Component { - init() { - super.init(...arguments); - instance = this; - } - } - ) - ); - - this.render('{{aria-test}}'); - - this.assertComponentElement(this.firstChild, { attrs: {} }); - - runTask(() => this.rerender()); - - this.assertComponentElement(this.firstChild, { attrs: {} }); - - runTask(() => instance.set('ariaRole', 'input')); - - this.assertComponentElement(this.firstChild, { attrs: {} }); - } - - ['@test `template` specified in component is overridden by block']() { - this.owner.register( - 'component:with-template', - setComponentTemplate( - precompileTemplate('[In layout - {{this.name}}] {{yield}}'), - class extends Component { - template = 'Should not be used'; - } - ) - ); - - this.render( - strip` - {{#with-template name="with-block"}} - [In block - {{this.name}}] - {{/with-template}} - {{with-template name="without-block"}}`, - { - name: 'Whoop, whoop!', - } - ); - - this.assertText( - '[In layout - with-block] [In block - Whoop, whoop!][In layout - without-block] ' - ); - - runTask(() => this.rerender()); - - this.assertText( - '[In layout - with-block] [In block - Whoop, whoop!][In layout - without-block] ' - ); - - runTask(() => this.context.set('name', 'Ole, ole')); - - this.assertText('[In layout - with-block] [In block - Ole, ole][In layout - without-block] '); - - runTask(() => this.context.set('name', 'Whoop, whoop!')); - - this.assertText( - '[In layout - with-block] [In block - Whoop, whoop!][In layout - without-block] ' - ); - } - - ['@test (has-block) is true when block supplied']() { - this.owner.register( - 'component:with-block', - template( - strip` - {{#if (has-block)}} - {{yield}} - {{else}} - No Block! - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render(strip` - {{#with-block}} - In template - {{/with-block}}`); - - this.assertText('In template'); - - runTask(() => this.rerender()); - - this.assertText('In template'); - } - - ['@test (has-block) is false when no block supplied']() { - this.owner.register( - 'component:with-block', - template( - strip` - {{#if (has-block)}} - {{yield}} - {{else}} - No Block! - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render('{{with-block}}'); - - this.assertText('No Block!'); - - runTask(() => this.rerender()); - - this.assertText('No Block!'); - } - - ['@test (has-block-params) is true when block param supplied']() { - this.owner.register( - 'component:with-block', - template( - strip` - {{#if (has-block-params)}} - {{yield this}} - In Component - {{else}} - {{yield}} No Block! - {{/if}} - `, - { component: class extends GlimmerComponent {}, strictMode: false } - ) - ); - - this.render(strip` - {{#with-block as |something|}} - In template - {{/with-block}}`); - - this.assertText('In template - In Component'); - - runTask(() => this.rerender()); - - this.assertText('In template - In Component'); - } - - ['@test (has-block-params) is false when no block param supplied']() { - this.owner.register( - 'component:with-block', - template( - strip` - {{#if (has-block-params)}} - {{yield this}} - {{else}} - {{yield}} No Block Param! - {{/if}} - `, - { component: class extends GlimmerComponent {}, strictMode: false } - ) - ); - - this.render(strip` - {{#with-block}} - In block - {{/with-block}}`); - - this.assertText('In block No Block Param!'); - - runTask(() => this.rerender()); - - this.assertText('In block No Block Param!'); - } - - ['@test static named positional parameters']() { - this.owner.register( - 'component:sample-component', - setComponentTemplate( - precompileTemplate('{{this.name}}{{this.age}}'), - class extends PositionalComponent { - static positionalParams = ['name', 'age']; - } - ) - ); - - this.render('{{sample-component "Quint" 4}}'); - - this.assertText('Quint4'); - - runTask(() => this.rerender()); - - this.assertText('Quint4'); - } - - ['@test dynamic named positional parameters']() { - this.owner.register( - 'component:sample-component', - setComponentTemplate( - precompileTemplate('{{this.name}}{{this.age}}'), - class extends PositionalComponent { - static positionalParams = ['name', 'age']; - } - ) - ); - - this.render('{{sample-component this.myName this.myAge}}', { - myName: 'Quint', - myAge: 4, - }); - - this.assertText('Quint4'); - - runTask(() => this.rerender()); - - this.assertText('Quint4'); - - runTask(() => this.context.set('myName', 'Sergio')); - - this.assertText('Sergio4'); - - runTask(() => this.context.set('myAge', 2)); - - this.assertText('Sergio2'); - - runTask(() => { - this.context.set('myName', 'Quint'); - this.context.set('myAge', 4); - }); - - this.assertText('Quint4'); - } - - ['@test if a value is passed as a non-positional parameter, it raises an assertion']() { - this.owner.register( - 'component:sample-component', - setComponentTemplate( - precompileTemplate('{{this.name}}'), - class extends Component { - static positionalParams = ['name']; - } - ) - ); - - expectAssertion(() => { - this.render('{{sample-component this.notMyName name=this.myName}}', { - myName: 'Quint', - notMyName: 'Sergio', - }); - }, 'You cannot specify both a positional param (at position 0) and the hash argument `name`.'); - } - - ['@test yield to inverse']() { - this.owner.register( - 'component:my-if', - template( - strip` - {{#if @predicate}} - Yes:{{yield @someValue}} - {{else}} - No:{{yield to="inverse"}} - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render( - strip` - {{#my-if predicate=this.activated someValue=42 as |result|}} - Hello{{result}} - {{else}} - Goodbye - {{/my-if}}`, - { - activated: true, - } - ); - - this.assertText('Yes:Hello42'); - - runTask(() => this.rerender()); - - this.assertText('Yes:Hello42'); - - runTask(() => this.context.set('activated', false)); - - this.assertText('No:Goodbye'); - - runTask(() => this.context.set('activated', true)); - - this.assertText('Yes:Hello42'); - } - - ['@test expression (has-block) inverse']() { - this.owner.register( - 'component:check-inverse', - template( - strip` - {{#if (has-block "inverse")}} - Yes - {{else}} - No - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render(strip` - {{#check-inverse}}{{/check-inverse}} - {{#check-inverse}}{{else}}{{/check-inverse}}`); - - this.assertHTML('NoYes'); - - this.assertStableRerender(); - } - - ['@test expression (has-block) default']() { - this.owner.register( - 'component:check-block', - template( - strip` - {{#if (has-block)}} - Yes - {{else}} - No - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render(strip` - {{check-block}} - {{#check-block}}{{/check-block}}`); - - this.assertHTML('NoYes'); - - this.assertStableRerender(); - } - - ['@test expression (has-block-params) inverse']() { - this.owner.register( - 'component:check-inverse', - template( - strip` - {{#if (has-block-params "inverse")}} - Yes - {{else}} - No - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render(strip` - {{#check-inverse}}{{/check-inverse}} - {{#check-inverse as |something|}}{{/check-inverse}}`); - - this.assertHTML('NoNo'); - - this.assertStableRerender(); - } - - ['@test expression (has-block-params) default']() { - this.owner.register( - 'component:check-block', - template( - strip` - {{#if (has-block-params)}} - Yes - {{else}} - No - {{/if}} - `, - { component: templateOnly(), strictMode: false } - ) - ); - - this.render(strip` - {{#check-block}}{{/check-block}} - {{#check-block as |something|}}{{/check-block}}`); - - this.assertHTML('NoYes'); - - this.assertStableRerender(); - } - - ['@test (has-block) expression in an attribute'](assert) { - this.owner.register( - 'component:check-attr', - setComponentTemplate( - precompileTemplate(''), - templateOnly() - ) - ); - - this.render(strip` - {{check-attr}} - {{#check-attr}}{{/check-attr}}`); - - equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); - equalsElement(assert, this.$('button')[1], 'button', { name: 'true' }, ''); - - this.assertStableRerender(); - } - - ['@test (has-block) inverse expression in an attribute'](assert) { - this.owner.register( - 'component:check-attr', - setComponentTemplate( - precompileTemplate(''), - templateOnly() - ) - ); - - this.render(strip` - {{#check-attr}}{{/check-attr}} - {{#check-attr}}{{else}}{{/check-attr}}`); - - equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); - equalsElement(assert, this.$('button')[1], 'button', { name: 'true' }, ''); - - this.assertStableRerender(); - } - - ['@test (has-block-params) expression in an attribute'](assert) { - this.owner.register( - 'component:check-attr', - setComponentTemplate( - precompileTemplate(''), - templateOnly() - ) - ); - - this.render(strip` - {{#check-attr}}{{/check-attr}} - {{#check-attr as |something|}}{{/check-attr}}`); - - equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); - equalsElement(assert, this.$('button')[1], 'button', { name: 'true' }, ''); - - this.assertStableRerender(); - } - - ['@test (has-block-params) inverse expression in an attribute'](assert) { - this.owner.register( - 'component:check-attr', - setComponentTemplate( - precompileTemplate(''), - templateOnly() - ) - ); - - this.render(strip` - {{#check-attr}}{{/check-attr}} - {{#check-attr as |something|}}{{/check-attr}}`); - - equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); - equalsElement(assert, this.$('button')[1], 'button', { name: 'false' }, ''); - - this.assertStableRerender(); - } - - ['@test (has-block) as a param to a helper']() { - this.owner.register( - 'component:check-helper', - setComponentTemplate( - precompileTemplate('{{if (has-block) "true" "false"}}'), - templateOnly() - ) - ); - - this.render(strip` - {{check-helper}} - {{#check-helper}}{{/check-helper}}`); - - this.assertHTML('falsetrue'); - - this.assertStableRerender(); - } - - ['@test (has-block) inverse as a param to a helper']() { - this.owner.register( - 'component:check-helper', - setComponentTemplate( - precompileTemplate('{{if (has-block "inverse") "true" "false"}}'), - templateOnly() - ) - ); - - this.render(strip` - {{#check-helper}}{{/check-helper}} - {{#check-helper}}{{else}}{{/check-helper}}`); - - this.assertHTML('falsetrue'); - - this.assertStableRerender(); - } - - ['@test (has-block-params) as a param to a helper']() { - this.owner.register( - 'component:check-helper', - setComponentTemplate( - precompileTemplate('{{if (has-block-params) "true" "false"}}'), - templateOnly() - ) - ); - - this.render(strip` - {{#check-helper}}{{/check-helper}} - {{#check-helper as |something|}}{{/check-helper}}`); - - this.assertHTML('falsetrue'); - - this.assertStableRerender(); - } - - ['@test (has-block-params) inverse as a param to a helper']() { - this.owner.register( - 'component:check-helper', - setComponentTemplate( - precompileTemplate('{{if (has-block-params "inverse") "true" "false"}}'), - templateOnly() - ) - ); - - this.render(strip` - {{#check-helper}}{{/check-helper}} - {{#check-helper as |something|}}{{/check-helper}}`); - - this.assertHTML('falsefalse'); - - this.assertStableRerender(); - } - - ['@test component in template of a yielding component should have the proper parentView']( - assert - ) { - let outer, innerTemplate, innerLayout; - - this.owner.register( - 'component:x-outer', - setComponentTemplate( - precompileTemplate('{{x-inner-in-layout}}{{yield}}'), - class extends Component { - init() { - super.init(...arguments); - outer = this; - } - } - ) - ); - - this.owner.register( - 'component:x-inner-in-template', - class extends Component { - init() { - super.init(...arguments); - innerTemplate = this; - } - } - ); - - this.owner.register( - 'component:x-inner-in-layout', - class extends Component { - init() { - super.init(...arguments); - innerLayout = this; - } - } - ); - - this.render('{{#x-outer}}{{x-inner-in-template}}{{/x-outer}}'); + runTask(() => this.context.set('prop', 'other thing there')); - assert.equal( - innerTemplate.parentView, - outer, - 'receives the wrapping component as its parentView in template blocks' - ); - assert.equal( - innerLayout.parentView, - outer, - 'receives the wrapping component as its parentView in layout' - ); - assert.equal( - outer.parentView, - this.context, - 'x-outer receives the ambient scope as its parentView' - ); + this.assertText('In layout - someProp: other thing there'); - runTask(() => this.rerender()); + runTask(() => this.context.set('prop', 'something here')); - assert.equal( - innerTemplate.parentView, - outer, - 'receives the wrapping component as its parentView in template blocks' - ); - assert.equal( - innerLayout.parentView, - outer, - 'receives the wrapping component as its parentView in layout' - ); - assert.equal( - outer.parentView, - this.context, - 'x-outer receives the ambient scope as its parentView' - ); + this.assertText('In layout - someProp: something here'); } - ['@test newly-added sub-components get correct parentView'](assert) { - let outer, inner; - - this.owner.register( - 'component:x-outer', - class extends Component { - init() { - super.init(...arguments); - outer = this; - } - } - ); + ['@test block with properties on attrs is asserted against']() { + expectAssertion(() => { + this.owner.register( + 'component:with-block', + template('In layout - someProp: {{attrs.someProp}} - {{yield}}', { + component: templateOnly(), + strictMode: false, + }) + ); + }, 'Using {{attrs}} to reference named arguments is not supported. {{attrs.someProp}} should be updated to {{@someProp}}. (L1:C24) '); + } + ['@test block with named argument']() { this.owner.register( - 'component:x-inner', - class extends Component { - init() { - super.init(...arguments); - inner = this; - } - } + 'component:with-block', + setComponentTemplate( + precompileTemplate('In layout - someProp: {{@someProp}} - {{yield}}'), + templateOnly() + ) ); this.render( strip` - {{#x-outer}} - {{#if this.showInner}} - {{x-inner}} - {{/if}} - {{/x-outer}}`, + {{#with-block someProp=this.prop}} + In template + {{/with-block}}`, { - showInner: false, + prop: 'something here', } ); - assert.equal( - outer.parentView, - this.context, - 'x-outer receives the ambient scope as its parentView' - ); + this.assertText('In layout - someProp: something here - In template'); runTask(() => this.rerender()); - assert.equal( - outer.parentView, - this.context, - 'x-outer receives the ambient scope as its parentView (after rerender)' - ); + this.assertText('In layout - someProp: something here - In template'); - runTask(() => this.context.set('showInner', true)); + runTask(() => this.context.set('prop', 'something else')); - assert.equal( - outer.parentView, - this.context, - 'x-outer receives the ambient scope as its parentView' - ); - assert.equal( - inner.parentView, - outer, - 'receives the wrapping component as its parentView in template blocks' - ); + this.assertText('In layout - someProp: something else - In template'); - runTask(() => this.context.set('showInner', false)); + runTask(() => this.context.set('prop', 'something here')); - assert.equal( - outer.parentView, - this.context, - 'x-outer receives the ambient scope as its parentView' - ); + this.assertText('In layout - someProp: something here - In template'); } - ["@test when a property is changed during children's rendering"]() { - let middle; - + ['@test static arbitrary number of positional parameters'](assert) { this.owner.register( - 'component:x-outer', - setComponentTemplate( - precompileTemplate('{{#x-middle}}{{x-inner value=this.value}}{{/x-middle}}'), - class extends GlimmerComponent { - value = 1; + 'component:sample-component', + template( + strip` + {{#each this.names as |name|}} + {{name}} + {{/each}} + `, + { + component: class extends PositionalComponent { + static positionalParams = 'names'; + }, + strictMode: false, } ) ); - class MiddleComponent extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - middle = this; - } - @tracked value = null; - } - - this.owner.register( - 'component:x-middle', - setComponentTemplate( - precompileTemplate('
    {{this.value}}
    {{yield}}'), - MiddleComponent - ) - ); + this.render(strip` +
    {{sample-component "Foo" 4 "Bar"}}
    +
    {{sample-component "Foo" 4 "Bar" 5 "Baz"}}
    `); - this.owner.register( - 'component:x-inner', - setComponentTemplate( - precompileTemplate('
    {{@value}}
    '), - class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - middle.value = this.args.value; - } - } - ) - ); + assert.equal(this.$('#args-3').text(), 'Foo4Bar'); + assert.equal(this.$('#args-5').text(), 'Foo4Bar5Baz'); - let expectedBacktrackingMessage = backtrackingMessageFor('value', MiddleComponent.name, { - renderTree: ['x-outer', 'x-middle', 'this.value'], - }); + runTask(() => this.rerender()); - expectAssertion(() => { - this.render('{{x-outer}}'); - }, expectedBacktrackingMessage); + assert.equal(this.$('#args-3').text(), 'Foo4Bar'); + assert.equal(this.$('#args-5').text(), 'Foo4Bar5Baz'); } - ["@test when a shared dependency is changed during children's rendering"]() { - this.owner.register( - 'component:x-outer', - setComponentTemplate( - precompileTemplate( - '
    {{this.wrapper.content}}
    {{x-inner value=this.value wrapper=this.wrapper}}' - ), - class extends GlimmerComponent { - value = 1; - wrapper = EmberObject.create({ content: null }); - } - ) - ); - + ['@test dynamic arbitrary number of positional parameters']() { this.owner.register( - 'component:x-inner', - setComponentTemplate( - precompileTemplate('
    {{@wrapper.content}}
    '), - class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - this.args.wrapper.set('content', this.args.value); - } + 'component:sample-component', + template( + strip` + {{#each this.n as |name|}} + {{name}} + {{/each}} + `, + { + component: class extends PositionalComponent { + static positionalParams = 'n'; + }, + strictMode: false, } ) ); - let expectedBacktrackingMessage = backtrackingMessageFor('content', '<.+?>', { - renderTree: ['x-outer', 'this.wrapper.content'], + this.render(`{{sample-component this.user1 this.user2}}`, { + user1: 'Foo', + user2: 4, }); - expectAssertion(() => { - this.render('{{x-outer}}'); - }, expectedBacktrackingMessage); - } + this.assertText('Foo4'); - ["@test when a shared dependency is changed during children's rendering (tracked)"]() { - class Wrapper { - @tracked content = null; - } + runTask(() => this.rerender()); - this.owner.register( - 'component:x-outer', - setComponentTemplate( - precompileTemplate( - '
    {{this.wrapper.content}}
    {{x-inner value=this.value wrapper=this.wrapper}}' - ), - class extends GlimmerComponent { - value = 1; - wrapper = new Wrapper(); - } - ) - ); + this.assertText('Foo4'); - this.owner.register( - 'component:x-inner', - setComponentTemplate( - precompileTemplate('
    {{@wrapper.content}}
    '), - class extends GlimmerComponent { - constructor(owner, args) { - super(owner, args); - this.args.wrapper.content = this.args.value; - } - } - ) - ); + runTask(() => this.context.set('user1', 'Bar')); - let expectedBacktrackingMessage = backtrackingMessageFor('content', Wrapper.name, { - renderTree: ['x-outer', 'this.wrapper.content'], + this.assertText('Bar4'); + + runTask(() => this.context.set('user2', '5')); + + this.assertText('Bar5'); + + runTask(() => { + this.context.set('user1', 'Foo'); + this.context.set('user2', 4); }); - expectAssertion(() => { - this.render('{{x-outer}}'); - }, expectedBacktrackingMessage); + this.assertText('Foo4'); } - ['@test non-block with each rendering child components']() { + ['@test (has-block) is true when block supplied']() { this.owner.register( - 'component:non-block', + 'component:with-block', template( strip` - In layout. {{#each @items as |item|}} - [{{child-non-block item=item}}] - {{/each}} + {{#if (has-block)}} + {{yield}} + {{else}} + No Block! + {{/if}} `, { component: templateOnly(), strictMode: false } ) ); - this.owner.register( - 'component:child-non-block', - setComponentTemplate(precompileTemplate('Child: {{@item}}.'), templateOnly()) - ); - - let items = emberA(['Tom', 'Dick', 'Harry']); - - this.render('{{non-block items=this.items}}', { items }); + this.render(strip` + {{#with-block}} + In template + {{/with-block}}`); - this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.]'); + this.assertText('In template'); runTask(() => this.rerender()); - this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.]'); - - runTask(() => this.context.get('items').pushObject('Sergio')); + this.assertText('In template'); + } - this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.][Child: Sergio.]'); + ['@test (has-block) is false when no block supplied']() { + this.owner.register( + 'component:with-block', + template( + strip` + {{#if (has-block)}} + {{yield}} + {{else}} + No Block! + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) + ); - runTask(() => this.context.get('items').shiftObject()); + this.render('{{with-block}}'); - this.assertText('In layout. [Child: Dick.][Child: Harry.][Child: Sergio.]'); + this.assertText('No Block!'); - runTask(() => this.context.set('items', emberA(['Tom', 'Dick', 'Harry']))); + runTask(() => this.rerender()); - this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.]'); + this.assertText('No Block!'); } - ['@test specifying classNames results in correct class'](assert) { + ['@test (has-block-params) is true when block param supplied']() { this.owner.register( - 'component:some-clicky-thing', - class extends Component { - tagName = 'button'; - classNames = ['foo', 'bar']; - } + 'component:with-block', + template( + strip` + {{#if (has-block-params)}} + {{yield this}} - In Component + {{else}} + {{yield}} No Block! + {{/if}} + `, + { component: class extends GlimmerComponent {}, strictMode: false } + ) ); this.render(strip` - {{#some-clicky-thing classNames="baz"}} - Click Me - {{/some-clicky-thing}}`); - - // TODO: ember-view is no longer viewable in the classNames array. Bug or - // feature? - let expectedClassNames = ['ember-view', 'foo', 'bar', 'baz']; - - assert.ok( - this.$('button').is('.foo.bar.baz.ember-view'), - `the element has the correct classes: ${this.$('button').attr('class')}` - ); - // `ember-view` is no longer in classNames. - // assert.deepEqual(clickyThing.get('classNames'), expectedClassNames, 'classNames are properly combined'); - this.assertComponentElement(this.firstChild, { - tagName: 'button', - attrs: { class: classes(expectedClassNames.join(' ')) }, - }); + {{#with-block as |something|}} + In template + {{/with-block}}`); + + this.assertText('In template - In Component'); runTask(() => this.rerender()); - assert.ok( - this.$('button').is('.foo.bar.baz.ember-view'), - `the element has the correct classes: ${this.$('button').attr('class')} (rerender)` - ); - // `ember-view` is no longer in classNames. - // assert.deepEqual(clickyThing.get('classNames'), expectedClassNames, 'classNames are properly combined (rerender)'); - this.assertComponentElement(this.firstChild, { - tagName: 'button', - attrs: { class: classes(expectedClassNames.join(' ')) }, - }); + this.assertText('In template - In Component'); } - ['@test specifying custom concatenatedProperties avoids clobbering']() { + ['@test (has-block-params) is false when no block param supplied']() { this.owner.register( - 'component:some-clicky-thing', + 'component:with-block', template( strip` - {{#each this.blahzz as |p|}} - {{p}} - {{/each}} - - {{yield}} + {{#if (has-block-params)}} + {{yield this}} + {{else}} + {{yield}} No Block Param! + {{/if}} `, - { - component: class extends Component { - concatenatedProperties = ['blahzz']; - blahzz = ['blark', 'pory']; - }, - strictMode: false, - } + { component: class extends GlimmerComponent {}, strictMode: false } ) ); this.render(strip` - {{#some-clicky-thing blahzz="baz"}} - Click Me - {{/some-clicky-thing}}`); + {{#with-block}} + In block + {{/with-block}}`); - this.assertText('blarkporybaz- Click Me'); + this.assertText('In block No Block Param!'); runTask(() => this.rerender()); - this.assertText('blarkporybaz- Click Me'); + this.assertText('In block No Block Param!'); } - ['@test a two way binding flows upstream when consumed in the template']() { - let component; - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - component = this; - } - }; + ['@test static named positional parameters']() { + this.owner.register( + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{this.name}}{{this.age}}'), + class extends PositionalComponent { + static positionalParams = ['name', 'age']; + } + ) + ); + + this.render('{{sample-component "Quint" 4}}'); + + this.assertText('Quint4'); + + runTask(() => this.rerender()); + + this.assertText('Quint4'); + } + ['@test dynamic named positional parameters']() { this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.bar}}'), FooBarComponent) + 'component:sample-component', + setComponentTemplate( + precompileTemplate('{{this.name}}{{this.age}}'), + class extends PositionalComponent { + static positionalParams = ['name', 'age']; + } + ) ); - this.render('{{this.localBar}} - {{foo-bar bar=this.localBar}}', { - localBar: 'initial value', + this.render('{{sample-component this.myName this.myAge}}', { + myName: 'Quint', + myAge: 4, }); - this.assertText('initial value - initial value'); + this.assertText('Quint4'); runTask(() => this.rerender()); - this.assertText('initial value - initial value'); + this.assertText('Quint4'); - runTask(() => { - component.set('bar', 'updated value'); - }); + runTask(() => this.context.set('myName', 'Sergio')); - this.assertText('updated value - updated value'); + this.assertText('Sergio4'); - runTask(() => { - component.set('bar', undefined); - }); + runTask(() => this.context.set('myAge', 2)); - this.assertText(' - '); + this.assertText('Sergio2'); runTask(() => { - this.component.set('localBar', 'initial value'); + this.context.set('myName', 'Quint'); + this.context.set('myAge', 4); }); - this.assertText('initial value - initial value'); + this.assertText('Quint4'); } - ['@test a two way binding flows upstream through a CP when consumed in the template']() { - let component; - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - component = this; - } + ['@test yield to inverse']() { + this.owner.register( + 'component:my-if', + template( + strip` + {{#if @predicate}} + Yes:{{yield @someValue}} + {{else}} + No:{{yield to="inverse"}} + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) + ); - @computed - get bar() { - return this._bar; - } - set bar(value) { - this._bar = value; + this.render( + strip` + {{#my-if predicate=this.activated someValue=42 as |result|}} + Hello{{result}} + {{else}} + Goodbye + {{/my-if}}`, + { + activated: true, } - }; + ); + + this.assertText('Yes:Hello42'); + + runTask(() => this.rerender()); + + this.assertText('Yes:Hello42'); + + runTask(() => this.context.set('activated', false)); + + this.assertText('No:Goodbye'); + + runTask(() => this.context.set('activated', true)); + + this.assertText('Yes:Hello42'); + } + ['@test yield to else']() { this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.bar}}'), FooBarComponent) + 'component:my-if', + template( + strip` + {{#if @predicate}} + Yes:{{yield @someValue}} + {{else}} + No:{{yield to="else"}} + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) ); - this.render('{{this.localBar}} - {{foo-bar bar=this.localBar}}', { - localBar: 'initial value', - }); + this.render( + strip` + {{#my-if predicate=this.activated someValue=42 as |result|}} + Hello{{result}} + {{else}} + Goodbye + {{/my-if}}`, + { + activated: true, + } + ); - this.assertText('initial value - initial value'); + this.assertText('Yes:Hello42'); runTask(() => this.rerender()); - this.assertText('initial value - initial value'); + this.assertText('Yes:Hello42'); - runTask(() => { - component.set('bar', 'updated value'); - }); + runTask(() => this.context.set('activated', false)); - this.assertText('updated value - updated value'); + this.assertText('No:Goodbye'); - runTask(() => { - this.component.set('localBar', 'initial value'); - }); + runTask(() => this.context.set('activated', true)); - this.assertText('initial value - initial value'); + this.assertText('Yes:Hello42'); } - ['@test a two way binding flows upstream through a CP without template consumption']() { - let component; - let FooBarComponent = class extends Component { - init() { - super.init(...arguments); - component = this; - } + ['@test expression (has-block) inverse']() { + this.owner.register( + 'component:check-inverse', + template( + strip` + {{#if (has-block "inverse")}} + Yes + {{else}} + No + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) + ); - @computed - get bar() { - return this._bar; - } + this.render(strip` + {{#check-inverse}}{{/check-inverse}} + {{#check-inverse}}{{else}}{{/check-inverse}}`); - set bar(value) { - this._bar = value; - } - }; + this.assertHTML('NoYes'); + + this.assertStableRerender(); + } + ['@test expression (has-block) default']() { this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate(''), FooBarComponent) + 'component:check-block', + template( + strip` + {{#if (has-block)}} + Yes + {{else}} + No + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) ); - this.render('{{this.localBar}}{{foo-bar bar=this.localBar}}', { - localBar: 'initial value', - }); + this.render(strip` + {{check-block}} + {{#check-block}}{{/check-block}}`); - this.assertText('initial value'); + this.assertHTML('NoYes'); - runTask(() => this.rerender()); + this.assertStableRerender(); + } + + ['@test expression (has-block-params) inverse']() { + this.owner.register( + 'component:check-inverse', + template( + strip` + {{#if (has-block-params "inverse")}} + Yes + {{else}} + No + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) + ); + + this.render(strip` + {{#check-inverse}}{{/check-inverse}} + {{#check-inverse as |something|}}{{/check-inverse}}`); - this.assertText('initial value'); + this.assertHTML('NoNo'); - runTask(() => { - component.set('bar', 'updated value'); - }); + this.assertStableRerender(); + } + + ['@test expression (has-block-params) default']() { + this.owner.register( + 'component:check-block', + template( + strip` + {{#if (has-block-params)}} + Yes + {{else}} + No + {{/if}} + `, + { component: templateOnly(), strictMode: false } + ) + ); - this.assertText('updated value'); + this.render(strip` + {{#check-block}}{{/check-block}} + {{#check-block as |something|}}{{/check-block}}`); - runTask(() => { - this.component.set('localBar', 'initial value'); - }); + this.assertHTML('NoYes'); - this.assertText('initial value'); + this.assertStableRerender(); } - ['@test GH#18417 - a two way binding flows upstream to a parent component through a CP']() { - let parent, child; - let ParentComponent = class extends Component { - init() { - super.init(...arguments); - parent = this; - } - - @tracked string = 'Hello|World'; - }; - + ['@test (has-block) expression in an attribute'](assert) { this.owner.register( - 'component:parent', + 'component:check-attr', setComponentTemplate( - precompileTemplate( - '{{child value=this.string}}Parent String={{this.string}}' - ), - ParentComponent + precompileTemplate(''), + templateOnly() ) ); - let ChildComponent = class extends Component { - init() { - super.init(...arguments); - child = this; - } - - a = null; // computed based on passed in value of `string` - b = null; // computed based on passed in value of `string` + this.render(strip` + {{check-attr}} + {{#check-attr}}{{/check-attr}}`); - @computed('a', 'b') - get value() { - return this.a + '|' + this.b; - } + equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); + equalsElement(assert, this.$('button')[1], 'button', { name: 'true' }, ''); - set value(value) { - let vals = value.split('|'); - this.set('a', vals[0]); - this.set('b', vals[1]); - } - }; + this.assertStableRerender(); + } + ['@test (has-block) inverse expression in an attribute'](assert) { this.owner.register( - 'component:child', - setComponentTemplate(precompileTemplate('{{this.value}}'), ChildComponent) - ); - - this.render('{{parent}}'); - - this.assert.equal(parent.string, 'Hello|World', 'precond - parent value'); - this.assert.equal( - this.element.querySelector('[data-test-parent-value]').textContent.trim(), - 'Hello|World', - 'precond - parent rendered value' + 'component:check-attr', + setComponentTemplate( + precompileTemplate(''), + templateOnly() + ) ); - runTask(() => { - child.set('a', 'Foo'); - }); - - this.assert.equal(parent.string, 'Foo|World', 'parent value updated'); - - this.assert.equal( - this.element.querySelector('[data-test-parent-value]').textContent.trim(), - 'Foo|World', - 'parent updated value rendered' - ); + this.render(strip` + {{#check-attr}}{{/check-attr}} + {{#check-attr}}{{else}}{{/check-attr}}`); - runTask(() => { - child.set('a', 'Hello'); - }); + equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); + equalsElement(assert, this.$('button')[1], 'button', { name: 'true' }, ''); - this.assert.equal(parent.string, 'Hello|World', 'parent value reset'); - this.assert.equal( - this.element.querySelector('[data-test-parent-value]').textContent.trim(), - 'Hello|World', - 'parent template reset' - ); + this.assertStableRerender(); } - ['@test services can be injected into components']() { - let serviceInstance; - this.registerService( - 'name', - class extends Service { - init() { - super.init(...arguments); - serviceInstance = this; - } - last = 'Jackson'; - } - ); - + ['@test (has-block-params) expression in an attribute'](assert) { this.owner.register( - 'component:foo-bar', + 'component:check-attr', setComponentTemplate( - precompileTemplate('{{this.name.last}}'), - class extends GlimmerComponent { - @service - name; - } + precompileTemplate(''), + templateOnly() ) ); - this.render('{{foo-bar}}'); - - this.assertText('Jackson'); + this.render(strip` + {{#check-attr}}{{/check-attr}} + {{#check-attr as |something|}}{{/check-attr}}`); - runTask(() => this.rerender()); + equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); + equalsElement(assert, this.$('button')[1], 'button', { name: 'true' }, ''); - this.assertText('Jackson'); + this.assertStableRerender(); + } - runTask(() => { - serviceInstance.set('last', 'McGuffey'); - }); + ['@test (has-block-params) inverse expression in an attribute'](assert) { + this.owner.register( + 'component:check-attr', + setComponentTemplate( + precompileTemplate(''), + templateOnly() + ) + ); - this.assertText('McGuffey'); + this.render(strip` + {{#check-attr}}{{/check-attr}} + {{#check-attr as |something|}}{{/check-attr}}`); - runTask(() => { - serviceInstance.set('last', 'Jackson'); - }); + equalsElement(assert, this.$('button')[0], 'button', { name: 'false' }, ''); + equalsElement(assert, this.$('button')[1], 'button', { name: 'false' }, ''); - this.assertText('Jackson'); + this.assertStableRerender(); } - ['@test injecting an unknown service raises an exception']() { + ['@test (has-block) as a param to a helper']() { this.owner.register( - 'component:foo-bar', - class extends Component { - @service - missingService; - } + 'component:check-helper', + setComponentTemplate( + precompileTemplate('{{if (has-block) "true" "false"}}'), + templateOnly() + ) ); - expectAssertion(() => { - this.render('{{foo-bar}}'); - }, "Attempting to inject an unknown injection: 'service:missingService'"); - } + this.render(strip` + {{check-helper}} + {{#check-helper}}{{/check-helper}}`); - ['@test throws if `super.init` is not called from `init`']() { - this.owner.register( - 'component:foo-bar', - class extends Component { - init() {} - } - ); + this.assertHTML('falsetrue'); - expectAssertion(() => { - this.render('{{foo-bar}}'); - }, /You must call `super.init\(...arguments\);` or `this._super\(...arguments\)` when overriding `init` on a framework object. Please update .*/); + this.assertStableRerender(); } - ['@test it can use readDOMAttr to read input value']() { - let component; - let assertElement = (expectedValue) => { - // value is a property, not an attribute - this.assertHTML(``); - this.assert.equal(this.firstChild.value, expectedValue, 'value property is correct'); - this.assert.equal( - get(component, 'value'), - expectedValue, - 'component.get("value") is correct' - ); - }; - + ['@test (has-block) inverse as a param to a helper']() { this.owner.register( - 'component:one-way-input', - class extends Component { - tagName = 'input'; - attributeBindings = ['value']; - - init() { - super.init(...arguments); - component = this; - } - - change() { - let value = this.readDOMAttr('value'); - this.set('value', value); - } - } + 'component:check-helper', + setComponentTemplate( + precompileTemplate('{{if (has-block "inverse") "true" "false"}}'), + templateOnly() + ) ); - this.render('{{one-way-input value=this.value}}', { - value: 'foo', - }); + this.render(strip` + {{#check-helper}}{{/check-helper}} + {{#check-helper}}{{else}}{{/check-helper}}`); - assertElement('foo'); + this.assertHTML('falsetrue'); this.assertStableRerender(); + } - runTask(() => { - this.firstChild.value = 'bar'; - this.$('input').trigger('change'); - }); + ['@test (has-block-params) as a param to a helper']() { + this.owner.register( + 'component:check-helper', + setComponentTemplate( + precompileTemplate('{{if (has-block-params) "true" "false"}}'), + templateOnly() + ) + ); - assertElement('bar'); + this.render(strip` + {{#check-helper}}{{/check-helper}} + {{#check-helper as |something|}}{{/check-helper}}`); - runTask(() => { - this.firstChild.value = 'foo'; - this.$('input').trigger('change'); - }); + this.assertHTML('falsetrue'); - assertElement('foo'); + this.assertStableRerender(); + } - runTask(() => { - set(component, 'value', 'bar'); - }); + ['@test (has-block-params) inverse as a param to a helper']() { + this.owner.register( + 'component:check-helper', + setComponentTemplate( + precompileTemplate('{{if (has-block-params "inverse") "true" "false"}}'), + templateOnly() + ) + ); - assertElement('bar'); + this.render(strip` + {{#check-helper}}{{/check-helper}} + {{#check-helper as |something|}}{{/check-helper}}`); - runTask(() => { - this.firstChild.value = 'foo'; - this.$('input').trigger('change'); - }); + this.assertHTML('falsefalse'); - assertElement('foo'); + this.assertStableRerender(); } - ['@test child triggers revalidate during parent destruction (GH#13846)']() { + ["@test when a property is changed during children's rendering"]() { + let middle; + this.owner.register( - 'component:x-select', + 'component:x-outer', setComponentTemplate( - precompileTemplate('{{yield this}}'), - class extends Component { - tagName = 'select'; - - init() { - super.init(...arguments); - this.options = emberA([]); - this.value = null; - } - - updateValue() { - let newValue = this.get('options.lastObject.value'); - - this.set('value', newValue); - } + precompileTemplate('{{#x-middle}}{{x-inner value=this.value}}{{/x-middle}}'), + class extends GlimmerComponent { + value = 1; + } + ) + ); - registerOption(option) { - this.get('options').addObject(option); - } + class MiddleComponent extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + middle = this; + } + @tracked value = null; + } - unregisterOption(option) { - this.get('options').removeObject(option); + this.owner.register( + 'component:x-middle', + setComponentTemplate( + precompileTemplate('
    {{this.value}}
    {{yield}}'), + MiddleComponent + ) + ); - this.updateValue(); + this.owner.register( + 'component:x-inner', + setComponentTemplate( + precompileTemplate('
    {{@value}}
    '), + class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + middle.value = this.args.value; } } ) ); - this.owner.register( - 'component:x-option', - class extends Component { - tagName = 'option'; - attributeBindings = ['selected']; - - didInsertElement() { - super.didInsertElement(...arguments); + let expectedBacktrackingMessage = backtrackingMessageFor('value', MiddleComponent.name, { + renderTree: ['x-outer', 'x-middle', 'this.value'], + }); - this.get('select').registerOption(this); - } + expectAssertion(() => { + this.render('{{x-outer}}'); + }, expectedBacktrackingMessage); + } - @computed('select.value') - get selected() { - return this.get('value') === this.get('select.value'); + ["@test when a shared dependency is changed during children's rendering"]() { + this.owner.register( + 'component:x-outer', + setComponentTemplate( + precompileTemplate( + '
    {{this.wrapper.content}}
    {{x-inner value=this.value wrapper=this.wrapper}}' + ), + class extends GlimmerComponent { + value = 1; + wrapper = EmberObject.create({ content: null }); } + ) + ); - willDestroyElement() { - super.willDestroyElement(...arguments); - this.get('select').unregisterOption(this); + this.owner.register( + 'component:x-inner', + setComponentTemplate( + precompileTemplate('
    {{@wrapper.content}}
    '), + class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + this.args.wrapper.set('content', this.args.value); + } } - } + ) ); - this.render(strip` - {{#x-select value=this.value as |select|}} - {{#x-option value="1" select=select}}1{{/x-option}} - {{#x-option value="2" select=select}}2{{/x-option}} - {{/x-select}} - `); - - this.teardown(); + let expectedBacktrackingMessage = backtrackingMessageFor('content', '<.+?>', { + renderTree: ['x-outer', 'this.wrapper.content'], + }); - this.assert.ok(true, 'no errors during teardown'); + expectAssertion(() => { + this.render('{{x-outer}}'); + }, expectedBacktrackingMessage); } - ['@test setting a property in willDestroyElement does not assert (GH#14273)'](assert) { - assert.expect(2); + ["@test when a shared dependency is changed during children's rendering (tracked)"]() { + class Wrapper { + @tracked content = null; + } this.owner.register( - 'component:foo-bar', + 'component:x-outer', setComponentTemplate( - precompileTemplate(`{{#if this.showFoo}}things{{/if}}`), - class extends Component { - init() { - super.init(...arguments); - this.showFoo = true; - } + precompileTemplate( + '
    {{this.wrapper.content}}
    {{x-inner value=this.value wrapper=this.wrapper}}' + ), + class extends GlimmerComponent { + value = 1; + wrapper = new Wrapper(); + } + ) + ); - willDestroyElement() { - this.set('showFoo', false); - assert.ok(true, 'willDestroyElement was fired'); - super.willDestroyElement(...arguments); + this.owner.register( + 'component:x-inner', + setComponentTemplate( + precompileTemplate('
    {{@wrapper.content}}
    '), + class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); + this.args.wrapper.content = this.args.value; } } ) ); - this.render(`{{foo-bar}}`); + let expectedBacktrackingMessage = backtrackingMessageFor('content', Wrapper.name, { + renderTree: ['x-outer', 'this.wrapper.content'], + }); - this.assertText('things'); + expectAssertion(() => { + this.render('{{x-outer}}'); + }, expectedBacktrackingMessage); } - async ['@test didReceiveAttrs fires after .init() but before observers become active'](assert) { - let barCopyDidChangeCount = 0; + ['@test non-block with each rendering child components']() { + this.owner.register( + 'component:non-block', + template( + strip` + In layout. {{#each @items as |item|}} + [{{child-non-block item=item}}] + {{/each}} + `, + { component: templateOnly(), strictMode: false } + ) + ); this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{this.bar}}-{{this.barCopy}}'), - Component.extend({ - init() { - this._super(...arguments); - this.didInit = true; - }, + 'component:child-non-block', + setComponentTemplate(precompileTemplate('Child: {{@item}}.'), templateOnly()) + ); - didReceiveAttrs() { - assert.ok(this.didInit, 'expected init to have run before didReceiveAttrs'); - this.set('barCopy', this.attrs.bar.value + 1); - }, + let items = trackedArray(['Tom', 'Dick', 'Harry']); - barCopyDidChange: observer('barCopy', () => { - barCopyDidChangeCount++; - }), - }) - ) - ); + this.render('{{non-block items=this.items}}', { items }); - await this.render(`{{foo-bar bar=this.bar}}`, { bar: 3 }); + this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.]'); + + runTask(() => this.rerender()); + + this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.]'); - this.assertText('3-4'); + runTask(() => this.context.get('items').push('Sergio')); - assert.strictEqual(barCopyDidChangeCount, 1, 'expected observer firing for: barCopy'); + this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.][Child: Sergio.]'); - set(this.context, 'bar', 7); + runTask(() => this.context.get('items').shift()); - await runLoopSettled(); + this.assertText('In layout. [Child: Dick.][Child: Harry.][Child: Sergio.]'); - this.assertText('7-8'); + runTask(() => this.context.set('items', ['Tom', 'Dick', 'Harry'])); - assert.strictEqual(barCopyDidChangeCount, 2, 'expected observer firing for: barCopy'); + this.assertText('In layout. [Child: Tom.][Child: Dick.][Child: Harry.]'); } - ['@test overriding didReceiveAttrs does not trigger deprecation'](assert) { - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate('{{this.foo}}-{{this.fooCopy}}-{{this.bar}}-{{this.barCopy}}'), - class extends Component { - didReceiveAttrs() { - assert.equal(1, this.get('foo'), 'expected attrs to have correct value'); - } + ['@test services can be injected into components']() { + let serviceInstance; + this.registerService( + 'name', + class extends Service { + init() { + super.init(...arguments); + serviceInstance = this; } - ) + last = 'Jackson'; + } ); - this.render(`{{foo-bar foo=this.foo bar=this.bar}}`, { foo: 1, bar: 3 }); - } - - ['@test overriding didUpdateAttrs does not trigger deprecation'](assert) { this.owner.register( 'component:foo-bar', setComponentTemplate( - precompileTemplate('{{this.foo}}-{{this.fooCopy}}-{{this.bar}}-{{this.barCopy}}'), - class extends Component { - didUpdateAttrs() { - assert.equal(5, this.get('foo'), 'expected newAttrs to have new value'); - } + precompileTemplate('{{this.name.last}}'), + class extends GlimmerComponent { + @service + name; } ) ); - this.render(`{{foo-bar foo=this.foo bar=this.bar}}`, { foo: 1, bar: 3 }); + this.render('{{foo-bar}}'); - runTask(() => set(this.context, 'foo', 5)); - } + this.assertText('Jackson'); - [`${testUnless(DEPRECATIONS.DEPRECATE_EVENTED.isRemoved)} @test triggering an event only attempts to invoke an identically named method, if it actually is a function (GH#15228)`]( - assert - ) { - assert.expect(5); - - let payload = ['arbitrary', 'event', 'data']; - - expectDeprecation( - () => { - this.owner.register( - 'component:evented-component', - Component.extend({ - someTruthyProperty: true, - - init() { - this._super(...arguments); - expectDeprecation( - () => { - this.trigger('someMethod', ...payload); - this.trigger('someTruthyProperty', ...payload); - }, - /Evented#trigger` is deprecated/, - DEPRECATIONS.DEPRECATE_EVENTED.isEnabled - ); - }, + runTask(() => this.rerender()); - someMethod(...data) { - assert.deepEqual( - data, - payload, - 'the method `someMethod` should be called, when `someMethod` is triggered' - ); - }, + this.assertText('Jackson'); - listenerForSomeMethod: on('someMethod', function (...data) { - assert.deepEqual( - data, - payload, - 'the listener `listenerForSomeMethod` should be called, when `someMethod` is triggered' - ); - }), - - listenerForSomeTruthyProperty: on('someTruthyProperty', function (...data) { - assert.deepEqual( - data, - payload, - 'the listener `listenerForSomeTruthyProperty` should be called, when `someTruthyProperty` is triggered' - ); - }), - }) - ); - }, - /`on\(\)` event decorator is deprecated/, - DEPRECATIONS.DEPRECATE_EVENTED.isEnabled - ); + runTask(() => { + serviceInstance.set('last', 'McGuffey'); + }); + + this.assertText('McGuffey'); + + runTask(() => { + serviceInstance.set('last', 'Jackson'); + }); - this.render(`{{evented-component}}`); + this.assertText('Jackson'); } ['@test component yielding in an {{#each}} has correct block values after rerendering (GH#14284)']() { @@ -3517,75 +1475,6 @@ moduleFor( this.assertText('|foo||bar||qux||baz|'); } - ['@test unimplimented positionalParams do not cause an error GH#14416']() { - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), class extends Component {}) - ); - - this.render('{{foo-bar this.wat}}'); - this.assertText('hello'); - } - - ['@test using attrs for positional params is asserted against']() { - let MyComponent = class extends Component {}; - - expectAssertion(() => { - this.owner.register( - 'component:foo-bar', - template( - 'MyVar1: {{attrs.myVar}} {{this.myVar}} MyVar2: {{this.myVar2}} {{attrs.myVar2}}', - { - component: MyComponent.reopenClass({ positionalParams: ['myVar'] }), - strictMode: false, - } - ) - ); - }, 'Using {{attrs}} to reference named arguments is not supported. {{attrs.myVar}} should be updated to {{@myVar}}. (L1:C10) '); - } - - // Perhaps change this test to `{{this.attrs.myVar.value}}` when removing the deprecation? - ['@test using this.attrs for positional params']() { - let MyComponent = class extends Component {}; - - expectDeprecation(() => { - this.owner.register( - 'component:foo-bar', - template( - 'MyVar1: {{this.attrs.myVar}} {{this.myVar}} MyVar2: {{this.myVar2}} {{this.attrs.myVar2}}', - { - component: MyComponent.reopenClass({ positionalParams: ['myVar'] }), - strictMode: false, - } - ) - ); - }, /Using {{this.attrs}} to reference named arguments has been deprecated. {{this.attrs.myVar2?}} should be updated to {{@myVar2?}}./); - - this.render('{{foo-bar 1 myVar2=2}}'); - - this.assertText('MyVar1: 1 1 MyVar2: 2 2'); - } - - ['@test using named arguments for positional params']() { - let MyComponent = class extends Component {}; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate( - precompileTemplate( - 'MyVar1: {{@myVar}} {{this.myVar}} MyVar2: {{this.myVar2}} {{@myVar2}}' - ), - MyComponent.reopenClass({ - positionalParams: ['myVar'], - }) - ) - ); - - this.render('{{foo-bar 1 myVar2=2}}'); - - this.assertText('MyVar1: 1 1 MyVar2: 2 2'); - } - ["@test can use `{{this}}` to emit the component's toString value [GH#14581]"]() { this.owner.register( 'component:foo-bar', @@ -3682,24 +1571,6 @@ moduleFor( this.assertText('3'); } - ['@test has attrs by didReceiveAttrs with native classes'](assert) { - class FooBarComponent extends Component { - constructor(injections) { - super(injections); - // analogous to class field defaults - this.foo = 'bar'; - } - - didReceiveAttrs() { - assert.equal(this.foo, 'bar', 'received default attrs correctly'); - } - } - - this.owner.register('component:foo-bar', FooBarComponent); - - this.render('{{foo-bar}}'); - } - ['@test ensure aliases are watched properly [GH#17243]']() { let fooInstance, barInstance; @@ -3805,122 +1676,127 @@ moduleFor( this.assertHTML('bar'); } - '@test lifecycle hooks are not tracked'() { + '@test `{{#let blah as |foo|}}` does not affect `` resolution'() { + this.owner.register( + 'component:foo', + setComponentTemplate(precompileTemplate('foo component'), templateOnly()) + ); + + this.render('{{#let "foo block param" as |foo|}}{{/let}}'); + + this.assertText('foo component'); + + runTask(() => this.rerender()); + + this.assertText('foo component'); + } + + ['@test class is applied before modifiers are installed'](assert) { + assert.expect(1); + this.owner.register( 'component:foo-bar', setComponentTemplate( - precompileTemplate('{{this.baz}}'), - class extends Component { - @tracked foo; + precompileTemplate('
    '), + class extends GlimmerComponent { + inserted = defineSimpleModifier((element) => { + assert.ok(element.classList.contains('foo-bar'), 'the class is on the element'); + }); + } + ) + ); + + this.render('{{foo-bar class="foo-bar"}}'); + } + + ['@test child triggers revalidate during parent destruction (GH#13846)']() { + this.owner.register( + 'component:x-select', + setComponentTemplate( + precompileTemplate(''), + class extends GlimmerComponent { + @tracked value = null; - willInsertElement() { - this.foo; - this.foo = 123; - } + options = []; - willRender() { - this.foo; - this.foo = 123; - } + updateValue() { + let last = this.options[this.options.length - 1]; - didRender() { - this.foo; - this.foo = 123; + this.value = last ? last.args.value : null; } - didReceiveAttrs() { - this.foo; - this.foo = 123; + registerOption(option) { + this.options.push(option); } - didUpdate() { - this.foo; - this.foo = 123; - } + unregisterOption(option) { + this.options.splice(this.options.indexOf(option), 1); - didUpdateAttrs() { - this.foo; - this.foo = 123; + this.updateValue(); } + } + ) + ); - didInsertElement() { - this.foo; - this.foo = 123; - } + this.owner.register( + 'component:x-option', + setComponentTemplate( + precompileTemplate(''), + class extends GlimmerComponent { + constructor(owner, args) { + super(owner, args); - willClearRender() { - this.foo; - this.foo = 123; + this.args.select.registerOption(this); } - willDestroyElement() { - this.foo; - this.foo = 123; + get selected() { + return this.args.value === this.args.select.value; } - didDestroyElement() { - this.foo; - this.foo = 123; + willDestroy() { + super.willDestroy(); + + this.args.select.unregisterOption(this); } } ) ); - this.render('{{#if this.cond}}{{foo-bar baz=this.value}}{{/if}}', { - cond: true, - value: 'hello', - }); - - this.assertComponentElement(this.firstChild, { content: 'hello' }); - - runTask(() => set(this.context, 'value', 'world')); + this.render(strip` + {{#x-select as |select|}} + {{#x-option value="1" select=select}}1{{/x-option}} + {{#x-option value="2" select=select}}2{{/x-option}} + {{/x-select}} + `); - this.assertComponentElement(this.firstChild, { content: 'world' }); + this.teardown(); - runTask(() => set(this.context, 'cond', false)); + this.assert.ok(true, 'no errors during teardown'); } - '@test tracked property mutation in init does not error'() { - // TODO: this should issue a deprecation, but since the curly manager - // uses an untracked frame for construction we don't (yet) + ['@test setting a tracked property in willDestroy does not assert (GH#14273)'](assert) { + assert.expect(2); + this.owner.register( 'component:foo-bar', setComponentTemplate( - precompileTemplate(`{{this.itemCount}}`), - class extends Component { - @tracked itemCount = 0; - - init() { - super.init(...arguments); + precompileTemplate(`{{#if this.showFoo}}things{{/if}}`), + class extends GlimmerComponent { + @tracked showFoo = true; - // first read the tracked property - let { itemCount } = this; + willDestroy() { + super.willDestroy(); - // then attempt to update the tracked property - this.itemCount = itemCount + 1; + this.showFoo = false; + assert.ok(true, 'willDestroy was fired'); } } ) ); - this.render(''); - - this.assertComponentElement(this.firstChild, { content: '1' }); - } - - '@test `{{#let blah as |foo|}}` does not affect `` resolution'() { - this.owner.register( - 'component:foo', - setComponentTemplate(precompileTemplate('foo component'), templateOnly()) - ); - - this.render('{{#let "foo block param" as |foo|}}{{/let}}'); - - this.assertText('foo component'); - - runTask(() => this.rerender()); + this.render(`{{foo-bar}}`); - this.assertText('foo component'); + this.assertText('things'); } } ); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/dynamic-components-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/dynamic-components-test.js index 787403ff838..a69a87e8e56 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/dynamic-components-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/dynamic-components-test.js @@ -7,10 +7,8 @@ import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; import Component from '@glimmer/component'; -import { Component as EmberComponent } from '../../utils/helpers'; import PositionalComponent from '../../utils/positional-component'; import { backtrackingMessageFor } from '../../utils/debug-stack'; -import GlimmerishComponent from '../../utils/glimmerish-component'; moduleFor( 'Components test: dynamic components', @@ -108,7 +106,7 @@ moduleFor( precompileTemplate('{{component this.componentName}}', { strictMode: true, }), - class extends GlimmerishComponent { + class extends Component { componentName = { name: 'not-a-component' }; } ); @@ -120,81 +118,6 @@ moduleFor( }, /The `{{component}}` helper received an invalid value\. In strict mode, it expects a component definition\./); } - ['@test it has an element']() { - let instance; - - let FooBarComponent = class extends EmberComponent { - init() { - super.init(); - instance = this; - } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) - ); - - this.render('{{component "foo-bar"}}'); - - let element1 = instance.element; - - this.assertComponentElement(element1, { content: 'hello' }); - - runTask(() => this.rerender()); - - let element2 = instance.element; - - this.assertComponentElement(element2, { content: 'hello' }); - - this.assertSameNode(element2, element1); - } - - ['@test it has the right parentView and childViews'](assert) { - let fooBarInstance, fooBarBazInstance; - - let FooBarComponent = class extends EmberComponent { - init() { - super.init(); - fooBarInstance = this; - } - }; - - let FooBarBazComponent = class extends EmberComponent { - init() { - super.init(); - fooBarBazInstance = this; - } - }; - - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('foo-bar {{foo-bar-baz}}'), FooBarComponent) - ); - this.owner.register( - 'component:foo-bar-baz', - setComponentTemplate(precompileTemplate('foo-bar-baz'), FooBarBazComponent) - ); - - this.render('{{component "foo-bar"}}'); - this.assertText('foo-bar foo-bar-baz'); - - assert.equal(fooBarInstance.parentView, this.component); - assert.equal(fooBarBazInstance.parentView, fooBarInstance); - - assert.deepEqual(this.component.childViews, [fooBarInstance]); - assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); - - runTask(() => this.rerender()); - this.assertText('foo-bar foo-bar-baz'); - - assert.equal(fooBarInstance.parentView, this.component); - assert.equal(fooBarBazInstance.parentView, fooBarInstance); - - assert.deepEqual(this.component.childViews, [fooBarInstance]); - assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); - } - ['@test it can render a basic component with a block']() { this.owner.register( 'component:foo-bar', diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/error-handling-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/error-handling-test.js index d16bac7f652..80811b35ec6 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/error-handling-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/error-handling-test.js @@ -1,13 +1,12 @@ import { DEBUG } from '@glimmer/env'; -import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; +import { moduleFor, RenderingTestCase, runTask, defineSimpleModifier } from 'internal-test-helpers'; import { set } from '@ember/object'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; import Component from '@glimmer/component'; -import { Component as EmberComponent } from '../../utils/helpers'; moduleFor( 'Errors thrown during render', @@ -109,22 +108,24 @@ moduleFor( } } - ['@test it can recover resets the transaction when an error is thrown during didInsertElement']( + ['@test it can recover resets the transaction when an error is thrown during modifier installation']( assert ) { let shouldThrow = true; - let FooBarComponent = class extends EmberComponent { - didInsertElement() { - super.didInsertElement(...arguments); + let FooBarComponent = class extends Component { + inserted = defineSimpleModifier(() => { if (shouldThrow) { throw new Error('silly mistake!'); } - } + }); }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('hello'), FooBarComponent) + setComponentTemplate( + precompileTemplate('
    hello
    '), + FooBarComponent + ) ); assert.throws(() => { @@ -148,9 +149,9 @@ moduleFor( ['@test it can recover resets the transaction when an error is thrown during destroy'](assert) { let shouldThrow = true; - let FooBarComponent = class extends EmberComponent { - destroy() { - super.destroy(...arguments); + let FooBarComponent = class extends Component { + willDestroy() { + super.willDestroy(...arguments); if (shouldThrow) { throw new Error('silly mistake!'); } diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/glimmer-component-test.gjs b/packages/@ember/-internals/glimmer/tests/integration/components/glimmer-component-test.gjs index ce561ae678a..383a6f1bb78 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/glimmer-component-test.gjs +++ b/packages/@ember/-internals/glimmer/tests/integration/components/glimmer-component-test.gjs @@ -1,4 +1,6 @@ -import { moduleFor, RenderingTestCase, runTask } from 'internal-test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; @@ -11,126 +13,128 @@ class Label { @tracked text = 'first'; } -moduleFor( - 'Components test: @glimmer/component', - class extends RenderingTestCase { - '@test isDestroying and isDestroyed transition as the component is torn down'(assert) { - let toggle = new Toggle(); - let instance; - - class Subject extends Component { - constructor() { - super(...arguments); - instance = this; - assert.step( - `constructor: isDestroying=${this.isDestroying} isDestroyed=${this.isDestroyed}` - ); - } - - willDestroy() { - super.willDestroy(); - assert.step( - `willDestroy: isDestroying=${this.isDestroying} isDestroyed=${this.isDestroyed}` - ); - } - - +module('Components test: @glimmer/component', function (hooks) { + setupRenderingTest(hooks); + + test('isDestroying and isDestroyed transition as the component is torn down', async function (assert) { + let toggle = new Toggle(); + let instance; + + class Subject extends Component { + constructor() { + super(...arguments); + instance = this; + assert.step( + `constructor: isDestroying=${this.isDestroying} isDestroyed=${this.isDestroyed}` + ); } - const Root = ; + willDestroy() { + super.willDestroy(); + assert.step( + `willDestroy: isDestroying=${this.isDestroying} isDestroyed=${this.isDestroyed}` + ); + } - this.renderComponent(Root, { expect: 'subject' }); + + } - assert.verifySteps(['constructor: isDestroying=false isDestroyed=false']); - assert.false(instance.isDestroying, 'isDestroying is false while rendered'); - assert.false(instance.isDestroyed, 'isDestroyed is false while rendered'); + await render(); - runTask(() => (toggle.isShowing = false)); + await assert.stableRender('subject'); + assert.verifySteps(['constructor: isDestroying=false isDestroyed=false']); + assert.false(instance.isDestroying, 'isDestroying is false while rendered'); + assert.false(instance.isDestroyed, 'isDestroyed is false while rendered'); - this.assertText(''); - assert.verifySteps(['willDestroy: isDestroying=true isDestroyed=false']); - assert.true(instance.isDestroying, 'isDestroying is true after teardown'); - assert.true(instance.isDestroyed, 'isDestroyed is true after teardown'); - } + toggle.isShowing = false; + await settled(); - '@test tracked state and args are reactive without re-constructing the component'(assert) { - let label = new Label(); - let instance; + assert.dom().hasNoText(); + assert.verifySteps(['willDestroy: isDestroying=true isDestroyed=false']); + assert.true(instance.isDestroying, 'isDestroying is true after teardown'); + assert.true(instance.isDestroyed, 'isDestroyed is true after teardown'); + }); - class Subject extends Component { - @tracked count = 0; + test('tracked state and args are reactive without re-constructing the component', async function (assert) { + let label = new Label(); + let instance; - constructor() { - super(...arguments); - instance = this; - assert.step('constructor'); - } + class Subject extends Component { + @tracked count = 0; - + constructor() { + super(...arguments); + instance = this; + assert.step('constructor'); } - const Root = ; - - this.renderComponent(Root, { expect: 'first-0' }); + + } - assert.verifySteps(['constructor']); + await render(); - runTask(() => instance.count++); + await assert.stableRender('first-0'); + assert.verifySteps(['constructor']); - this.assertText('first-1'); + instance.count++; + await settled(); - runTask(() => (label.text = 'second')); + assert.dom().hasText('first-1'); - this.assertText('second-1'); - assert.verifySteps([], 'the component updated in place rather than being re-constructed'); - } + label.text = 'second'; + await settled(); - '@test re-mounting constructs a new instance and leaves the old one destroyed'(assert) { - let toggle = new Toggle(); - let instances = []; + assert.dom().hasText('second-1'); + assert.verifySteps([], 'the component updated in place rather than being re-constructed'); + }); - class Subject extends Component { - constructor() { - super(...arguments); - instances.push(this); - assert.step(`constructor: ${instances.length}`); - } + test('re-mounting constructs a new instance and leaves the old one destroyed', async function (assert) { + let toggle = new Toggle(); + let instances = []; - willDestroy() { - super.willDestroy(); - assert.step(`willDestroy: ${instances.indexOf(this) + 1}`); - } + class Subject extends Component { + constructor() { + super(...arguments); + instances.push(this); + assert.step(`constructor: ${instances.length}`); + } - + willDestroy() { + super.willDestroy(); + assert.step(`willDestroy: ${instances.indexOf(this) + 1}`); } - const Root = ; + + } - this.renderComponent(Root, { expect: 'subject' }); + await render(); - assert.verifySteps(['constructor: 1']); + await assert.stableRender('subject'); + assert.verifySteps(['constructor: 1']); - runTask(() => (toggle.isShowing = false)); + toggle.isShowing = false; + await settled(); - this.assertText(''); - assert.verifySteps(['willDestroy: 1']); + assert.dom().hasNoText(); + assert.verifySteps(['willDestroy: 1']); - runTask(() => (toggle.isShowing = true)); + toggle.isShowing = true; + await settled(); - this.assertText('subject'); - assert.verifySteps(['constructor: 2']); + assert.dom().hasText('subject'); + assert.verifySteps(['constructor: 2']); - assert.strictEqual(instances.length, 2, 'a second instance was constructed'); - assert.notStrictEqual(instances[0], instances[1], 'the instances are distinct'); - assert.true(instances[0].isDestroyed, 'the first instance stayed destroyed'); - assert.false(instances[1].isDestroying, 'the second instance is not destroying'); - assert.false(instances[1].isDestroyed, 'the second instance is not destroyed'); + assert.strictEqual(instances.length, 2, 'a second instance was constructed'); + assert.notStrictEqual(instances[0], instances[1], 'the instances are distinct'); + assert.true(instances[0].isDestroyed, 'the first instance stayed destroyed'); + assert.false(instances[1].isDestroying, 'the second instance is not destroying'); + assert.false(instances[1].isDestroyed, 'the second instance is not destroyed'); - runTask(() => (toggle.isShowing = false)); + toggle.isShowing = false; + await settled(); - this.assertText(''); - assert.verifySteps(['willDestroy: 2']); - assert.true(instances[1].isDestroyed, 'the second instance was destroyed in turn'); - } - } -); + assert.dom().hasNoText(); + assert.verifySteps(['willDestroy: 2']); + assert.true(instances[1].isDestroyed, 'the second instance was destroyed in turn'); + }); +}); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/life-cycle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/life-cycle-test.js index b989a47b24a..891e26d4a66 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/life-cycle-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/life-cycle-test.js @@ -1,1369 +1,33 @@ -import { classes, moduleFor, RenderingTestCase, runTask, strip } from 'internal-test-helpers'; +import { moduleFor, RenderingTestCase, runTask, defineSimpleModifier } from 'internal-test-helpers'; import { schedule } from '@ember/runloop'; -import { set, setProperties } from '@ember/object'; -import { A as emberA } from '@ember/array'; -import { getViewElement, getViewId } from '@ember/-internals/views'; +import { set } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@glimmer/manager'; -import { Component } from '../../utils/helpers'; -import { addListener } from '@ember/-internals/metal'; - -class LifeCycleHooksTest extends RenderingTestCase { - constructor() { - super(...arguments); - this.hooks = []; - this.components = {}; - this.componentRegistry = []; - this.teardownAssertions = []; - this.viewRegistry = this.owner.lookup('-view-registry:main'); - } - - afterEach() { - super.afterEach(); - - for (let i = 0; i < this.teardownAssertions.length; i++) { - this.teardownAssertions[i](); - } - } - - get isInteractive() { - return true; - } - - getBootOptions() { - return { - isInteractive: this.isInteractive, - }; - } - - /* abstract */ - get ComponentClass() { - throw new Error('Not implemented: `ComponentClass`'); - } - - /* abstract */ - invocationFor(/* name, namedArgs = {} */) { - throw new Error('Not implemented: `invocationFor`'); - } - - /* abstract */ - attrFor(/* name */) { - throw new Error('Not implemented: `attrFor`'); - } - - get boundHelpers() { - return { - invoke: bind(this.invocationFor, this), - attr: bind(this.attrFor, this), - }; - } - - assertRegisteredViews(label) { - let viewRegistry = this.viewRegistry; - let topLevelId = getViewId(this.component); - let actual = Object.keys(viewRegistry) - .sort() - .filter((id) => id !== topLevelId); - - if (this.isInteractive) { - let expected = this.componentRegistry.sort(); - - this.assert.deepEqual(actual, expected, 'registered views - ' + label); - } else { - this.assert.deepEqual(actual, [], 'no views should be registered for non-interactive mode'); - } - } - - registerComponent(name, { template = null }) { - let pushComponent = (instance) => { - this.components[name] = instance; - this.componentRegistry.push(getViewId(instance)); - }; - - let removeComponent = (instance) => { - let index = this.componentRegistry.indexOf(getViewId(instance)); - this.componentRegistry.splice(index, 1); - - delete this.components[name]; - }; - - let pushHook = (hookName, args) => { - this.hooks.push(hook(name, hookName, args)); - }; - - let assertParentView = (hookName, instance) => { - this.assert.ok(instance.parentView, `parentView should be present in ${hookName}`); - - if (hookName === 'willDestroyElement') { - this.assert.ok( - instance.parentView.childViews.indexOf(instance) !== -1, - `view is still connected to parentView in ${hookName}` - ); - } - }; - - let assertElement = (hookName, instance, inDOM = true) => { - if (instance.tagName === '') { - return; - } - - this.assert.ok( - getViewElement(instance), - `element should be present on ${instance} during ${hookName}` - ); - - if (this.isInteractive) { - this.assert.ok( - instance.element, - `this.element should be present on ${instance} during ${hookName}` - ); - this.assert.equal( - document.body.contains(instance.element), - inDOM, - `element for ${instance} ${ - inDOM ? 'should' : 'should not' - } be in the DOM during ${hookName}` - ); - } else { - this.assert.throws( - () => instance.element, - /Accessing `this.element` is not allowed in non-interactive environments/ - ); - } - }; - - let assertNoElement = (hookName, instance) => { - this.assert.strictEqual( - getViewElement(instance), - null, - `element should not be present in ${hookName}` - ); - - if (this.isInteractive) { - this.assert.strictEqual( - instance.element, - null, - `this.element should not be present in ${hookName}` - ); - } else { - this.assert.throws( - () => instance.element, - /Accessing `this.element` is not allowed in non-interactive environments/ - ); - } - }; - - let assertState = (hookName, expectedState, instance) => { - this.assert.equal( - instance._state, - expectedState, - `within ${hookName} the expected _state is ${expectedState}` - ); - }; - - let { isInteractive } = this; - - let ComponentClass = class extends this.ComponentClass { - init() { - super.init(...arguments); - - this.isInitialRender = true; - this.componentName = name; - pushHook('init'); - pushComponent(this); - assertParentView('init', this); - assertNoElement('init', this); - assertState('init', 'preRender', this); - - addListener(this, 'init', () => pushHook('on(init)')); - - schedule('afterRender', () => { - this.isInitialRender = false; - }); - } - - didReceiveAttrs(options) { - pushHook('didReceiveAttrs', options); - assertParentView('didReceiveAttrs', this); - - if (this.isInitialRender) { - assertNoElement('didReceiveAttrs', this); - assertState('didReceiveAttrs', 'preRender', this); - } else { - assertElement('didReceiveAttrs', this); - - if (isInteractive) { - assertState('didReceiveAttrs', 'inDOM', this); - } else { - assertState('didReceiveAttrs', 'hasElement', this); - } - } - } - - willInsertElement() { - pushHook('willInsertElement'); - assertParentView('willInsertElement', this); - assertElement('willInsertElement', this, false); - assertState('willInsertElement', 'hasElement', this); - } - - willRender() { - pushHook('willRender'); - assertParentView('willRender', this); - - if (this.isInitialRender) { - assertNoElement('willRender', this, false); - assertState('willRender', 'preRender', this); - } else { - assertElement('willRender', this); - assertState('willRender', 'inDOM', this); - } - } - - didInsertElement() { - pushHook('didInsertElement'); - assertParentView('didInsertElement', this); - assertElement('didInsertElement', this); - assertState('didInsertElement', 'inDOM', this); - } - - didRender() { - pushHook('didRender'); - assertParentView('didRender', this); - assertElement('didRender', this); - assertState('didRender', 'inDOM', this); - } - - didUpdateAttrs(options) { - pushHook('didUpdateAttrs', options); - assertParentView('didUpdateAttrs', this); - - if (isInteractive) { - assertState('didUpdateAttrs', 'inDOM', this); - } else { - assertState('didUpdateAttrs', 'hasElement', this); - } - } - - willUpdate(options) { - pushHook('willUpdate', options); - assertParentView('willUpdate', this); - assertElement('willUpdate', this); - assertState('willUpdate', 'inDOM', this); - } - - didUpdate(options) { - pushHook('didUpdate', options); - assertParentView('didUpdate', this); - assertElement('didUpdate', this); - assertState('didUpdate', 'inDOM', this); - } - - willDestroyElement() { - pushHook('willDestroyElement'); - assertParentView('willDestroyElement', this); - assertElement('willDestroyElement', this); - assertState('willDestroyElement', 'inDOM', this); - } - - willClearRender() { - pushHook('willClearRender'); - assertParentView('willClearRender', this); - assertElement('willClearRender', this); - assertState('willClearRender', 'inDOM', this); - } - - didDestroyElement() { - pushHook('didDestroyElement'); - assertNoElement('didDestroyElement', this); - assertState('didDestroyElement', 'destroying', this); - } - - willDestroy() { - pushHook('willDestroy'); - removeComponent(this); - - this._super(...arguments); - } - }; - - if (typeof template === 'string') { - setComponentTemplate(this.compile(template), ComponentClass); - } - this.owner.register(`component:${name}`, ComponentClass); - } - - assertHooks({ label, interactive, nonInteractive }) { - let rawHooks = this.isInteractive ? interactive : nonInteractive; - let hooks = rawHooks.map((raw) => hook(...raw)); - this.assert.deepEqual(json(this.hooks), json(hooks), label); - this.hooks = []; - } - - ['@test lifecycle hooks are invoked in a predictable order']() { - let { attr, invoke } = this.boundHelpers; - - this.registerComponent('the-top', { - template: strip` -
    - Twitter: {{${attr('twitter')}}}| - ${invoke('the-middle', { name: string('Tom Dale') })} -
    `, - }); - - this.registerComponent('the-middle', { - template: strip` -
    - Name: {{${attr('name')}}}| - ${invoke('the-bottom', { website: string('tomdale.net') })} -
    `, - }); - - this.registerComponent('the-bottom', { - template: strip` -
    - Website: {{${attr('website')}}} -
    `, - }); - - this.render(invoke('the-top', { twitter: expr(attr('twitter')) }), { - twitter: '@tomdale', - }); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - this.assertRegisteredViews('intial render'); - - this.assertHooks({ - label: 'after initial render', - - interactive: [ - // Sync hooks - - ['the-top', 'init'], - ['the-top', 'on(init)'], - ['the-top', 'didReceiveAttrs'], - ['the-top', 'willRender'], - ['the-top', 'willInsertElement'], - - ['the-middle', 'init'], - ['the-middle', 'on(init)'], - ['the-middle', 'didReceiveAttrs'], - ['the-middle', 'willRender'], - ['the-middle', 'willInsertElement'], - - ['the-bottom', 'init'], - ['the-bottom', 'on(init)'], - ['the-bottom', 'didReceiveAttrs'], - ['the-bottom', 'willRender'], - ['the-bottom', 'willInsertElement'], - - // Async hooks - - ['the-bottom', 'didInsertElement'], - ['the-bottom', 'didRender'], - - ['the-middle', 'didInsertElement'], - ['the-middle', 'didRender'], - - ['the-top', 'didInsertElement'], - ['the-top', 'didRender'], - ], - - nonInteractive: [ - // Sync hooks - ['the-top', 'init'], - ['the-top', 'on(init)'], - ['the-top', 'didReceiveAttrs'], - - ['the-middle', 'init'], - ['the-middle', 'on(init)'], - ['the-middle', 'didReceiveAttrs'], - - ['the-bottom', 'init'], - ['the-bottom', 'on(init)'], - ['the-bottom', 'didReceiveAttrs'], - ], - }); - - runTask(() => this.components['the-bottom'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (bottom)', - - interactive: [ - // Sync hooks - ['the-top', 'willUpdate'], - ['the-top', 'willRender'], - - ['the-middle', 'willUpdate'], - ['the-middle', 'willRender'], - - ['the-bottom', 'willUpdate'], - ['the-bottom', 'willRender'], - - // Async hooks - - ['the-bottom', 'didUpdate'], - ['the-bottom', 'didRender'], - - ['the-middle', 'didUpdate'], - ['the-middle', 'didRender'], - - ['the-top', 'didUpdate'], - ['the-top', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => this.components['the-middle'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (middle)', - - interactive: [ - // Sync hooks - - ['the-top', 'willUpdate'], - ['the-top', 'willRender'], - - ['the-middle', 'willUpdate'], - ['the-middle', 'willRender'], - - // Async hooks - - ['the-middle', 'didUpdate'], - ['the-middle', 'didRender'], - - ['the-top', 'didUpdate'], - ['the-top', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => this.components['the-top'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (top)', - - interactive: [ - // Sync hooks - - ['the-top', 'willUpdate'], - ['the-top', 'willRender'], - - // Async hooks - - ['the-top', 'didUpdate'], - ['the-top', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => set(this.context, 'twitter', '@horsetomdale')); - - this.assertText('Twitter: @horsetomdale|Name: Tom Dale|Website: tomdale.net'); - - // Because the `twitter` attr is only used by the topmost component, - // and not passed down, we do not expect to see lifecycle hooks - // called for child components. If the `didReceiveAttrs` hook used - // the new attribute to rerender itself imperatively, that would result - // in lifecycle hooks being invoked for the child. - - this.assertHooks({ - label: 'after update', - - interactive: [ - // Sync hooks - - ['the-top', 'didUpdateAttrs'], - ['the-top', 'didReceiveAttrs'], - - ['the-top', 'willUpdate'], - ['the-top', 'willRender'], - - // Async hooks - - ['the-top', 'didUpdate'], - ['the-top', 'didRender'], - ], - - nonInteractive: [ - // Sync hooks - ['the-top', 'didUpdateAttrs'], - ['the-top', 'didReceiveAttrs'], - ], - }); - - this.teardownAssertions.push(() => { - this.assertHooks({ - label: 'destroy', - - interactive: [ - ['the-top', 'willDestroyElement'], - ['the-top', 'willClearRender'], - ['the-middle', 'willDestroyElement'], - ['the-middle', 'willClearRender'], - ['the-bottom', 'willDestroyElement'], - ['the-bottom', 'willClearRender'], - ['the-top', 'didDestroyElement'], - ['the-middle', 'didDestroyElement'], - ['the-bottom', 'didDestroyElement'], - ['the-top', 'willDestroy'], - ['the-middle', 'willDestroy'], - ['the-bottom', 'willDestroy'], - ], - - nonInteractive: [ - ['the-top', 'willDestroy'], - ['the-middle', 'willDestroy'], - ['the-bottom', 'willDestroy'], - ], - }); - - this.assertRegisteredViews('after destroy'); - }); - } - - ['@test lifecycle hooks are invoked in a correct sibling order']() { - let { attr, invoke } = this.boundHelpers; - - this.registerComponent('the-parent', { - template: strip` -
    - ${invoke('the-first-child', { twitter: expr(attr('twitter')) })}| - ${invoke('the-second-child', { name: expr(attr('name')) })}| - ${invoke('the-last-child', { website: expr(attr('website')) })} -
    `, - }); - - this.registerComponent('the-first-child', { - template: `Twitter: {{${attr('twitter')}}}`, - }); - - this.registerComponent('the-second-child', { - template: `Name: {{${attr('name')}}}`, - }); - - this.registerComponent('the-last-child', { - template: `Website: {{${attr('website')}}}`, - }); - - this.render( - invoke('the-parent', { - twitter: expr(attr('twitter')), - name: expr(attr('name')), - website: expr(attr('website')), - }), - { - twitter: '@tomdale', - name: 'Tom Dale', - website: 'tomdale.net', - } - ); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - this.assertRegisteredViews('intial render'); - - this.assertHooks({ - label: 'after initial render', - - interactive: [ - // Sync hooks - - ['the-parent', 'init'], - ['the-parent', 'on(init)'], - ['the-parent', 'didReceiveAttrs'], - ['the-parent', 'willRender'], - ['the-parent', 'willInsertElement'], - - ['the-first-child', 'init'], - ['the-first-child', 'on(init)'], - ['the-first-child', 'didReceiveAttrs'], - ['the-first-child', 'willRender'], - ['the-first-child', 'willInsertElement'], - - ['the-second-child', 'init'], - ['the-second-child', 'on(init)'], - ['the-second-child', 'didReceiveAttrs'], - ['the-second-child', 'willRender'], - ['the-second-child', 'willInsertElement'], - - ['the-last-child', 'init'], - ['the-last-child', 'on(init)'], - ['the-last-child', 'didReceiveAttrs'], - ['the-last-child', 'willRender'], - ['the-last-child', 'willInsertElement'], - - // Async hooks - - ['the-first-child', 'didInsertElement'], - ['the-first-child', 'didRender'], - - ['the-second-child', 'didInsertElement'], - ['the-second-child', 'didRender'], - - ['the-last-child', 'didInsertElement'], - ['the-last-child', 'didRender'], - - ['the-parent', 'didInsertElement'], - ['the-parent', 'didRender'], - ], - - nonInteractive: [ - // Sync hooks - - ['the-parent', 'init'], - ['the-parent', 'on(init)'], - ['the-parent', 'didReceiveAttrs'], - - ['the-first-child', 'init'], - ['the-first-child', 'on(init)'], - ['the-first-child', 'didReceiveAttrs'], - - ['the-second-child', 'init'], - ['the-second-child', 'on(init)'], - ['the-second-child', 'didReceiveAttrs'], - - ['the-last-child', 'init'], - ['the-last-child', 'on(init)'], - ['the-last-child', 'didReceiveAttrs'], - ], - }); - - runTask(() => this.components['the-first-child'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (first child)', - - interactive: [ - // Sync hooks - - ['the-parent', 'willUpdate'], - ['the-parent', 'willRender'], - - ['the-first-child', 'willUpdate'], - ['the-first-child', 'willRender'], - - // Async hooks - - ['the-first-child', 'didUpdate'], - ['the-first-child', 'didRender'], - - ['the-parent', 'didUpdate'], - ['the-parent', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => this.components['the-second-child'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (second child)', - - interactive: [ - // Sync hooks - - ['the-parent', 'willUpdate'], - ['the-parent', 'willRender'], - - ['the-second-child', 'willUpdate'], - ['the-second-child', 'willRender'], - - // Async hooks - - ['the-second-child', 'didUpdate'], - ['the-second-child', 'didRender'], - - ['the-parent', 'didUpdate'], - ['the-parent', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => this.components['the-last-child'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (last child)', - - interactive: [ - // Sync hooks - - ['the-parent', 'willUpdate'], - ['the-parent', 'willRender'], - - ['the-last-child', 'willUpdate'], - ['the-last-child', 'willRender'], - - // Async hooks - - ['the-last-child', 'didUpdate'], - ['the-last-child', 'didRender'], - - ['the-parent', 'didUpdate'], - ['the-parent', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => this.components['the-parent'].rerender()); - - this.assertText('Twitter: @tomdale|Name: Tom Dale|Website: tomdale.net'); - - this.assertHooks({ - label: 'after no-op rerender (parent)', - - interactive: [ - // Sync hooks - - ['the-parent', 'willUpdate'], - ['the-parent', 'willRender'], - - // Async hooks - - ['the-parent', 'didUpdate'], - ['the-parent', 'didRender'], - ], - - nonInteractive: [], - }); - - runTask(() => - setProperties(this.context, { - twitter: '@horsetomdale', - name: 'Horse Tom Dale', - website: 'horsetomdale.net', - }) - ); - - this.assertText('Twitter: @horsetomdale|Name: Horse Tom Dale|Website: horsetomdale.net'); - - this.assertHooks({ - label: 'after update', - - interactive: [ - // Sync hooks - - ['the-parent', 'didUpdateAttrs'], - ['the-parent', 'didReceiveAttrs'], - - ['the-parent', 'willUpdate'], - ['the-parent', 'willRender'], - - ['the-first-child', 'didUpdateAttrs'], - ['the-first-child', 'didReceiveAttrs'], - - ['the-first-child', 'willUpdate'], - ['the-first-child', 'willRender'], - - ['the-second-child', 'didUpdateAttrs'], - ['the-second-child', 'didReceiveAttrs'], - - ['the-second-child', 'willUpdate'], - ['the-second-child', 'willRender'], - - ['the-last-child', 'didUpdateAttrs'], - ['the-last-child', 'didReceiveAttrs'], - - ['the-last-child', 'willUpdate'], - ['the-last-child', 'willRender'], - - // Async hooks - - ['the-first-child', 'didUpdate'], - ['the-first-child', 'didRender'], - - ['the-second-child', 'didUpdate'], - ['the-second-child', 'didRender'], - - ['the-last-child', 'didUpdate'], - ['the-last-child', 'didRender'], - - ['the-parent', 'didUpdate'], - ['the-parent', 'didRender'], - ], - - nonInteractive: [ - // Sync hooks - - ['the-parent', 'didUpdateAttrs'], - ['the-parent', 'didReceiveAttrs'], - - ['the-first-child', 'didUpdateAttrs'], - ['the-first-child', 'didReceiveAttrs'], - - ['the-second-child', 'didUpdateAttrs'], - ['the-second-child', 'didReceiveAttrs'], - - ['the-last-child', 'didUpdateAttrs'], - ['the-last-child', 'didReceiveAttrs'], - ], - }); - - this.teardownAssertions.push(() => { - this.assertHooks({ - label: 'destroy', - - interactive: [ - ['the-parent', 'willDestroyElement'], - ['the-parent', 'willClearRender'], - ['the-first-child', 'willDestroyElement'], - ['the-first-child', 'willClearRender'], - ['the-second-child', 'willDestroyElement'], - ['the-second-child', 'willClearRender'], - ['the-last-child', 'willDestroyElement'], - ['the-last-child', 'willClearRender'], - - ['the-parent', 'didDestroyElement'], - ['the-first-child', 'didDestroyElement'], - ['the-second-child', 'didDestroyElement'], - ['the-last-child', 'didDestroyElement'], - - ['the-parent', 'willDestroy'], - ['the-first-child', 'willDestroy'], - ['the-second-child', 'willDestroy'], - ['the-last-child', 'willDestroy'], - ], - - nonInteractive: [ - ['the-parent', 'willDestroy'], - ['the-first-child', 'willDestroy'], - ['the-second-child', 'willDestroy'], - ['the-last-child', 'willDestroy'], - ], - }); - - this.assertRegisteredViews('after destroy'); - }); - } - - ['@test passing values through attrs causes lifecycle hooks to fire if the attribute values have changed']() { - let { attr, invoke } = this.boundHelpers; - - this.registerComponent('the-top', { - template: strip` -
    - Top: ${invoke('the-middle', { twitterTop: expr(attr('twitter')) })} -
    `, - }); - - this.registerComponent('the-middle', { - template: strip` -
    - Middle: ${invoke('the-bottom', { - twitterMiddle: expr(attr('twitterTop')), - })} -
    `, - }); - - this.registerComponent('the-bottom', { - template: strip` -
    - Bottom: {{${attr('twitterMiddle')}}} -
    `, - }); - - this.render(invoke('the-top', { twitter: expr(attr('twitter')) }), { - twitter: '@tomdale', - }); - - this.assertText('Top: Middle: Bottom: @tomdale'); - this.assertRegisteredViews('intial render'); - - this.assertHooks({ - label: 'after initial render', - - interactive: [ - // Sync hooks - - ['the-top', 'init'], - ['the-top', 'on(init)'], - ['the-top', 'didReceiveAttrs'], - ['the-top', 'willRender'], - ['the-top', 'willInsertElement'], - - ['the-middle', 'init'], - ['the-middle', 'on(init)'], - ['the-middle', 'didReceiveAttrs'], - ['the-middle', 'willRender'], - ['the-middle', 'willInsertElement'], - - ['the-bottom', 'init'], - ['the-bottom', 'on(init)'], - ['the-bottom', 'didReceiveAttrs'], - ['the-bottom', 'willRender'], - ['the-bottom', 'willInsertElement'], - - // Async hooks - - ['the-bottom', 'didInsertElement'], - ['the-bottom', 'didRender'], - - ['the-middle', 'didInsertElement'], - ['the-middle', 'didRender'], - - ['the-top', 'didInsertElement'], - ['the-top', 'didRender'], - ], - - nonInteractive: [ - // Sync hooks - - ['the-top', 'init'], - ['the-top', 'on(init)'], - ['the-top', 'didReceiveAttrs'], - - ['the-middle', 'init'], - ['the-middle', 'on(init)'], - ['the-middle', 'didReceiveAttrs'], - - ['the-bottom', 'init'], - ['the-bottom', 'on(init)'], - ['the-bottom', 'didReceiveAttrs'], - ], - }); - - runTask(() => set(this.context, 'twitter', '@horsetomdale')); - - this.assertText('Top: Middle: Bottom: @horsetomdale'); - - // Because the `twitter` attr is used by the all of the components, - // the lifecycle hooks are invoked for all components. - - this.assertHooks({ - label: 'after updating (root)', - - interactive: [ - // Sync hooks - - ['the-top', 'didUpdateAttrs'], - ['the-top', 'didReceiveAttrs'], - - ['the-top', 'willUpdate'], - ['the-top', 'willRender'], - - ['the-middle', 'didUpdateAttrs'], - ['the-middle', 'didReceiveAttrs'], - - ['the-middle', 'willUpdate'], - ['the-middle', 'willRender'], - - ['the-bottom', 'didUpdateAttrs'], - ['the-bottom', 'didReceiveAttrs'], - - ['the-bottom', 'willUpdate'], - ['the-bottom', 'willRender'], - - // Async hooks - - ['the-bottom', 'didUpdate'], - ['the-bottom', 'didRender'], - - ['the-middle', 'didUpdate'], - ['the-middle', 'didRender'], - - ['the-top', 'didUpdate'], - ['the-top', 'didRender'], - ], - - nonInteractive: [ - // Sync hooks - - ['the-top', 'didUpdateAttrs'], - ['the-top', 'didReceiveAttrs'], - - ['the-middle', 'didUpdateAttrs'], - ['the-middle', 'didReceiveAttrs'], - - ['the-bottom', 'didUpdateAttrs'], - ['the-bottom', 'didReceiveAttrs'], - ], - }); - - runTask(() => this.rerender()); - - this.assertText('Top: Middle: Bottom: @horsetomdale'); - - // In this case, because the attrs are passed down, all child components are invoked. - - this.assertHooks({ - label: 'after no-op rernder (root)', - interactive: [], - nonInteractive: [], - }); - - this.teardownAssertions.push(() => { - this.assertHooks({ - label: 'destroy', - - interactive: [ - ['the-top', 'willDestroyElement'], - ['the-top', 'willClearRender'], - ['the-middle', 'willDestroyElement'], - ['the-middle', 'willClearRender'], - ['the-bottom', 'willDestroyElement'], - ['the-bottom', 'willClearRender'], - ['the-top', 'didDestroyElement'], - ['the-middle', 'didDestroyElement'], - ['the-bottom', 'didDestroyElement'], - ['the-top', 'willDestroy'], - ['the-middle', 'willDestroy'], - ['the-bottom', 'willDestroy'], - ], - - nonInteractive: [ - ['the-top', 'willDestroy'], - ['the-middle', 'willDestroy'], - ['the-bottom', 'willDestroy'], - ], - }); - - this.assertRegisteredViews('after destroy'); - }); - } - - ['@test components rendered from `{{each}}` have correct life-cycle hooks to be called']() { - let { invoke } = this.boundHelpers; - - this.registerComponent('nested-item', { template: `{{yield}}` }); - - this.registerComponent('an-item', { - template: strip` - {{#nested-item}}Item: {{this.count}}{{/nested-item}} - `, - }); - - this.registerComponent('no-items', { - template: strip` - {{#nested-item}}Nothing to see here{{/nested-item}} - `, - }); - - this.render( - strip` - {{#each this.items as |item|}} - ${invoke('an-item', { count: expr('item') })} - {{else}} - ${invoke('no-items')} - {{/each}} - `, - { - items: [1, 2, 3, 4, 5], - } - ); - - this.assertText('Item: 1Item: 2Item: 3Item: 4Item: 5'); - this.assertRegisteredViews('intial render'); - - let initialHooks = () => { - let ret = [ - ['an-item', 'init'], - ['an-item', 'on(init)'], - ['an-item', 'didReceiveAttrs'], - ]; - if (this.isInteractive) { - ret.push(['an-item', 'willRender'], ['an-item', 'willInsertElement']); - } - ret.push( - ['nested-item', 'init'], - ['nested-item', 'on(init)'], - ['nested-item', 'didReceiveAttrs'] - ); - if (this.isInteractive) { - ret.push(['nested-item', 'willRender'], ['nested-item', 'willInsertElement']); - } - return ret; - }; - - let initialAfterRenderHooks = () => { - if (this.isInteractive) { - return [ - ['nested-item', 'didInsertElement'], - ['nested-item', 'didRender'], - ['an-item', 'didInsertElement'], - ['an-item', 'didRender'], - ]; - } else { - return []; - } - }; - - this.assertHooks({ - label: 'after initial render', - - interactive: [ - // Sync hooks - ...initialHooks(1), - ...initialHooks(2), - ...initialHooks(3), - ...initialHooks(4), - ...initialHooks(5), - - // Async hooks - ...initialAfterRenderHooks(5), - ...initialAfterRenderHooks(4), - ...initialAfterRenderHooks(3), - ...initialAfterRenderHooks(2), - ...initialAfterRenderHooks(1), - ], - - nonInteractive: [ - // Sync hooks - ...initialHooks(1), - ...initialHooks(2), - ...initialHooks(3), - ...initialHooks(4), - ...initialHooks(5), - - // Async hooks - ...initialAfterRenderHooks(5), - ...initialAfterRenderHooks(4), - ...initialAfterRenderHooks(3), - ...initialAfterRenderHooks(2), - ...initialAfterRenderHooks(1), - ], - }); - - // TODO: Is this correct? Should childViews be populated in non-interactive mode? - if (this.isInteractive) { - this.assert.equal(this.component.childViews.length, 5, 'childViews precond'); - } - - runTask(() => set(this.context, 'items', [])); - - // TODO: Is this correct? Should childViews be populated in non-interactive mode? - if (this.isInteractive) { - this.assert.equal(this.component.childViews.length, 1, 'childViews updated'); - } - - this.assertText('Nothing to see here'); - - this.assertHooks({ - label: 'reset to empty array', - - interactive: [ - ['an-item', 'willDestroyElement'], - ['an-item', 'willClearRender'], - ['nested-item', 'willDestroyElement'], - ['nested-item', 'willClearRender'], - ['an-item', 'willDestroyElement'], - ['an-item', 'willClearRender'], - ['nested-item', 'willDestroyElement'], - ['nested-item', 'willClearRender'], - ['an-item', 'willDestroyElement'], - ['an-item', 'willClearRender'], - ['nested-item', 'willDestroyElement'], - ['nested-item', 'willClearRender'], - ['an-item', 'willDestroyElement'], - ['an-item', 'willClearRender'], - ['nested-item', 'willDestroyElement'], - ['nested-item', 'willClearRender'], - ['an-item', 'willDestroyElement'], - ['an-item', 'willClearRender'], - ['nested-item', 'willDestroyElement'], - ['nested-item', 'willClearRender'], - - ['no-items', 'init'], - ['no-items', 'on(init)'], - ['no-items', 'didReceiveAttrs'], - ['no-items', 'willRender'], - ['no-items', 'willInsertElement'], - - ['nested-item', 'init'], - ['nested-item', 'on(init)'], - ['nested-item', 'didReceiveAttrs'], - ['nested-item', 'willRender'], - ['nested-item', 'willInsertElement'], - - ['nested-item', 'didInsertElement'], - ['nested-item', 'didRender'], - ['no-items', 'didInsertElement'], - ['no-items', 'didRender'], - - ['an-item', 'didDestroyElement'], - ['nested-item', 'didDestroyElement'], - ['an-item', 'didDestroyElement'], - ['nested-item', 'didDestroyElement'], - ['an-item', 'didDestroyElement'], - ['nested-item', 'didDestroyElement'], - ['an-item', 'didDestroyElement'], - ['nested-item', 'didDestroyElement'], - ['an-item', 'didDestroyElement'], - ['nested-item', 'didDestroyElement'], - - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ], - - nonInteractive: [ - ['no-items', 'init'], - ['no-items', 'on(init)'], - ['no-items', 'didReceiveAttrs'], - - ['nested-item', 'init'], - ['nested-item', 'on(init)'], - ['nested-item', 'didReceiveAttrs'], - - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ['an-item', 'willDestroy'], - ['nested-item', 'willDestroy'], - ], - }); - - this.teardownAssertions.push(() => { - this.assertHooks({ - label: 'destroy', - - interactive: [ - ['no-items', 'willDestroyElement'], - ['no-items', 'willClearRender'], - ['nested-item', 'willDestroyElement'], - ['nested-item', 'willClearRender'], - - ['no-items', 'didDestroyElement'], - ['nested-item', 'didDestroyElement'], - - ['no-items', 'willDestroy'], - ['nested-item', 'willDestroy'], - ], - - nonInteractive: [ - ['no-items', 'willDestroy'], - ['nested-item', 'willDestroy'], - ], - }); - - this.assertRegisteredViews('after destroy'); - }); - } -} - -class CurlyComponentsTest extends LifeCycleHooksTest { - get ComponentClass() { - return Component; - } - - invocationFor(name, namedArgs = {}) { - let attrs = Object.keys(namedArgs) - .map((k) => `${k}=${this.val(namedArgs[k])}`) - .join(' '); - return `{{${name} ${attrs}}}`; - } - - attrFor(name) { - return `this.${name}`; - } - - /* private */ - val(value) { - if (value.isString) { - return JSON.stringify(value.value); - } else if (value.isExpr) { - return `(readonly ${value.value})`; - } else { - throw new Error(`Unknown value: ${value}`); - } - } -} - -moduleFor( - 'Components test: interactive lifecycle hooks (curly components)', - class extends CurlyComponentsTest { - get isInteractive() { - return true; - } - } -); - -moduleFor( - 'Components test: non-interactive lifecycle hooks (curly components)', - class extends CurlyComponentsTest { - get isInteractive() { - return false; - } - } -); - -moduleFor( - 'Components test: interactive lifecycle hooks (tagless curly components)', - class extends CurlyComponentsTest { - get ComponentClass() { - return class extends Component { - tagName = ''; - }; - } - - get isInteractive() { - return true; - } - } -); - -moduleFor( - 'Components test: non-interactive lifecycle hooks (tagless curly components)', - class extends CurlyComponentsTest { - get ComponentClass() { - return class extends Component { - tagName = ''; - }; - } - - get isInteractive() { - return false; - } - } -); +import Component from '@glimmer/component'; moduleFor( 'Run loop and lifecycle hooks', class extends RenderingTestCase { ['@test afterRender set']() { let ComponentClass = class extends Component { - width = '5'; - didInsertElement() { + @tracked width = '5'; + + inserted = defineSimpleModifier(() => { schedule('afterRender', () => { - this.set('width', '10'); + this.width = '10'; }); - } + }); }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.width}}'), ComponentClass) + setComponentTemplate( + precompileTemplate('
    {{this.width}}
    '), + ComponentClass + ) ); this.render('{{foo-bar}}'); @@ -1377,17 +41,19 @@ moduleFor( ['@test afterRender set on parent']() { let ComponentClass = class extends Component { - didInsertElement() { + inserted = defineSimpleModifier(() => { schedule('afterRender', () => { - let parent = this.get('parent'); - parent.set('foo', 'wat'); + set(this.args.parent, 'foo', 'wat'); }); - } + }); }; this.owner.register( 'component:foo-bar', - setComponentTemplate(precompileTemplate('{{this.foo}}'), ComponentClass) + setComponentTemplate( + precompileTemplate('
    {{@foo}}
    '), + ComponentClass + ) ); this.render('{{foo-bar parent=this foo=this.foo}}'); @@ -1399,243 +65,114 @@ moduleFor( this.assertText('wat'); } - ['@test `willRender` can set before render (GH#14458)']() { - let ComponentClass = class extends Component { - tagName = 'a'; - customHref = 'http://google.com'; - attributeBindings = ['customHref:href']; - willRender() { - this.set('customHref', 'http://willRender.com'); - } - }; + ['@test lifecycle hooks are invoked in a predictable order'](assert) { + let hooks = []; - this.owner.register( - 'component:foo-bar', - setComponentTemplate(precompileTemplate('Hello World'), ComponentClass) - ); - - this.render(`{{foo-bar id="foo"}}`); - - this.assertElement(this.firstChild, { - tagName: 'a', - attrs: { - id: 'foo', - href: 'http://willRender.com', - class: classes('ember-view'), - }, - }); - } + let componentClass = (name) => + class extends Component { + constructor(owner, args) { + super(owner, args); + hooks.push(`${name}: constructor`); + } - ['@test that thing about destroying'](assert) { - let ParentDestroyedElements = []; - let ChildDestroyedElements = []; + modifier = defineSimpleModifier(() => { + hooks.push(`${name}: modifier installed`); - let ParentComponent = class extends Component { - willDestroyElement() { - ParentDestroyedElements.push({ - id: this.itemId, - name: 'parent-component', - hasParent: Boolean(this.element.parentNode), - nextSibling: Boolean(this.element.nextSibling), - previousSibling: Boolean(this.element.previousSibling), + return () => hooks.push(`${name}: modifier destroyed`); }); - } - }; - let NestedComponent = class extends Component { - willDestroyElement() { - ChildDestroyedElements.push({ - id: this.nestedId, - name: 'nested-component', - hasParent: Boolean(this.element.parentNode), - nextSibling: Boolean(this.element.nextSibling), - previousSibling: Boolean(this.element.previousSibling), - }); - } - }; + willDestroy() { + super.willDestroy(); + hooks.push(`${name}: willDestroy`); + } + }; this.owner.register( - 'component:parent-component', + 'component:the-top', setComponentTemplate( precompileTemplate( - "{{yield}}
      {{#nested-component nestedId=(concat this.itemId '-A')}}A{{/nested-component}}{{#nested-component nestedId=(concat this.itemId '-B')}}B{{/nested-component}}
    " + '
    {{the-middle value=@value}}{{the-sibling}}
    ' ), - ParentComponent + componentClass('the-top') ) ); this.owner.register( - 'component:nested-component', - setComponentTemplate(precompileTemplate('{{yield}}'), NestedComponent) + 'component:the-middle', + setComponentTemplate( + precompileTemplate('
    {{the-bottom value=@value}}
    '), + componentClass('the-middle') + ) ); - let array = emberA([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]); - - this.render( - strip` - {{#each this.items as |item|}} - {{#parent-component itemId=item.id}}{{item.id}}{{/parent-component}} - {{/each}} - {{#if this.model.shouldShow}} - {{#parent-component itemId=6}}6{{/parent-component}} - {{/if}} - {{#if this.model.shouldShow}} - {{#parent-component itemId=7}}7{{/parent-component}} - {{/if}} - `, - { - items: array, - model: { shouldShow: true }, - } + this.owner.register( + 'component:the-bottom', + setComponentTemplate( + precompileTemplate('
    {{@value}}
    '), + componentClass('the-bottom') + ) ); - this.assertText('1AB2AB3AB4AB5AB6AB7AB'); + this.owner.register( + 'component:the-sibling', + setComponentTemplate( + precompileTemplate('
    '), + componentClass('the-sibling') + ) + ); - runTask(() => { - array.removeAt(2); - array.removeAt(2); - set(this.context, 'model.shouldShow', false); + this.render('{{#if this.show}}{{the-top value=this.value}}{{/if}}', { + show: true, + value: 1, }); - this.assertText('1AB2AB5AB'); + this.assertText('1'); - assertDestroyHooks( - assert, - [...ParentDestroyedElements], + assert.deepEqual( + hooks, [ - { - id: 3, - hasParent: true, - nextSibling: true, - previousSibling: true, - }, - { - id: 4, - hasParent: true, - nextSibling: true, - previousSibling: true, - }, - { - id: 6, - hasParent: true, - nextSibling: true, - previousSibling: true, - }, - { - id: 7, - hasParent: true, - nextSibling: false, - previousSibling: true, - }, - ] + 'the-top: constructor', + 'the-middle: constructor', + 'the-bottom: constructor', + 'the-sibling: constructor', + 'the-bottom: modifier installed', + 'the-middle: modifier installed', + 'the-sibling: modifier installed', + 'the-top: modifier installed', + ], + 'initial render' ); - assertDestroyHooks( - assert, - [...ChildDestroyedElements], - [ - { - id: '3-A', - hasParent: true, - nextSibling: true, - previousSibling: false, - }, - { - id: '3-B', - hasParent: true, - nextSibling: false, - previousSibling: true, - }, - { - id: '4-A', - hasParent: true, - nextSibling: true, - previousSibling: false, - }, - { - id: '4-B', - hasParent: true, - nextSibling: false, - previousSibling: true, - }, - { - id: '6-A', - hasParent: true, - nextSibling: true, - previousSibling: false, - }, - { - id: '6-B', - hasParent: true, - nextSibling: false, - previousSibling: true, - }, - { - id: '7-A', - hasParent: true, - nextSibling: true, - previousSibling: false, - }, - { - id: '7-B', - hasParent: true, - nextSibling: false, - previousSibling: true, - }, - ] - ); - } + hooks = []; - ['@test lifecycle hooks exist on the base class'](assert) { - // Make sure we get the finalized component prototype - let prototype = Component.proto(); + runTask(() => this.rerender()); - assert.equal(typeof prototype.didDestroyElement, 'function', 'didDestroyElement exists'); - assert.equal(typeof prototype.didInsertElement, 'function', 'didInsertElement exists'); - assert.equal(typeof prototype.didReceiveAttrs, 'function', 'didReceiveAttrs exists'); - assert.equal(typeof prototype.didRender, 'function', 'didRender exists'); - assert.equal(typeof prototype.didUpdate, 'function', 'didUpdate exists'); - assert.equal(typeof prototype.didUpdateAttrs, 'function', 'didUpdateAttrs exists'); - assert.equal(typeof prototype.willClearRender, 'function', 'willClearRender exists'); - assert.equal(typeof prototype.willDestroy, 'function', 'willDestroy exists'); - assert.equal(typeof prototype.willDestroyElement, 'function', 'willDestroyElement exists'); - assert.equal(typeof prototype.willInsertElement, 'function', 'willInsertElement exists'); - assert.equal(typeof prototype.willRender, 'function', 'willRender exists'); - assert.equal(typeof prototype.willUpdate, 'function', 'willUpdate exists'); - } - } -); + assert.deepEqual(hooks, [], 'a rerender without changes runs no hook'); -function assertDestroyHooks(assert, _actual, _expected) { - _expected.forEach((expected, i) => { - let name = expected.name; - assert.equal(expected.id, _actual[i].id, `${name} id is the same`); - assert.equal(expected.hasParent, _actual[i].hasParent, `${name} has parent node`); - assert.equal(expected.nextSibling, _actual[i].nextSibling, `${name} has next sibling node`); - assert.equal( - expected.previousSibling, - _actual[i].previousSibling, - `${name} has previous sibling node` - ); - }); -} + runTask(() => set(this.context, 'value', 2)); -function bind(func, thisArg) { - return (...args) => func.apply(thisArg, args); -} + this.assertText('2'); -function string(value) { - return { isString: true, value }; -} + assert.deepEqual(hooks, [], 'an argument update runs no hook'); -function expr(value) { - return { isExpr: true, value }; -} + runTask(() => set(this.context, 'show', false)); -function hook(name, hook, { attrs, oldAttrs, newAttrs } = {}) { - return { name, hook, args: { attrs, oldAttrs, newAttrs } }; -} + this.assertText(''); -function json(serializable) { - return JSON.parse(JSON.stringify(serializable)); -} + assert.deepEqual( + hooks, + [ + 'the-bottom: modifier destroyed', + 'the-middle: modifier destroyed', + 'the-sibling: modifier destroyed', + 'the-top: modifier destroyed', + 'the-top: willDestroy', + 'the-middle: willDestroy', + 'the-bottom: willDestroy', + 'the-sibling: willDestroy', + ], + 'destroy' + ); + } + } +); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js index 2a4d84bedd1..46c8c7db092 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js @@ -6,7 +6,7 @@ import { runTask, } from 'internal-test-helpers'; import Controller, { inject as injectController } from '@ember/controller'; -import { A as emberA } from '@ember/array'; +import { trackedArray } from '@ember/reactive/collections'; import { RSVP } from '@ember/-internals/runtime'; import Route from '@ember/routing/route'; import NoneLocation from '@ember/routing/none-location'; @@ -1607,7 +1607,7 @@ moduleFor( controller = this; } - routeNames = emberA(['foo', 'bar', 'rar']); + routeNames = trackedArray(['foo', 'bar', 'rar']); route1 = 'bar'; route2 = 'foo'; } @@ -1649,7 +1649,7 @@ moduleFor( linksEqual(this.$('a'), ['/foo', '/bar', '/rar', '/foo', '/bar', '/rar', '/rar', '/foo']); - runTask(() => controller.routeNames.shiftObject()); + runTask(() => controller.routeNames.shift()); linksEqual(this.$('a'), ['/bar', '/rar', '/bar', '/rar', '/rar', '/foo']); } diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js index e1313862bba..72c318843fa 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js @@ -5,7 +5,7 @@ import { runTask, } from 'internal-test-helpers'; import Controller, { inject as injectController } from '@ember/controller'; -import { A as emberA } from '@ember/array'; +import { trackedArray } from '@ember/reactive/collections'; import { RSVP } from '@ember/-internals/runtime'; import Route from '@ember/routing/route'; import NoneLocation from '@ember/routing/none-location'; @@ -1523,7 +1523,7 @@ moduleFor( controller = this; } - routeNames = emberA(['foo', 'bar', 'rar']); + routeNames = trackedArray(['foo', 'bar', 'rar']); route1 = 'bar'; route2 = 'foo'; } @@ -1567,7 +1567,7 @@ moduleFor( linksEqual(this.$('a'), ['/foo', '/bar', '/rar', '/foo', '/bar', '/rar', '/rar', '/foo']); - runTask(() => controller.routeNames.shiftObject()); + runTask(() => controller.routeNames.shift()); linksEqual(this.$('a'), ['/bar', '/rar', '/bar', '/rar', '/rar', '/foo']); } diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts b/packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts index 479cafe6b6e..4f3b9c91f87 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts @@ -8,7 +8,6 @@ import { defineSimpleModifier, moduleFor, type ClassicComponentShape, - runDestroy, } from 'internal-test-helpers'; import { Input, Textarea } from '@ember/component'; @@ -17,12 +16,13 @@ import { template } from '@ember/template-compiler/runtime'; import { template as compileTimeTemplate } from '@ember/template-compiler'; import { setComponentTemplate } from '@glimmer/manager'; import templateOnly from '@ember/component/template-only'; -import { array, concat, fn, get, hash, on } from '@glimmer/runtime'; -import GlimmerishComponent from '../../utils/glimmerish-component'; +import { array, concat, fn, get, hash } from '@ember/helper'; +import { on } from '@ember/modifier'; +import GlimmerComponent from '@glimmer/component'; -import { run } from '@ember/runloop'; import { destroy, associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; -import { renderComponent, type RenderResult } from '../../../lib/renderer'; +import { renderComponent, renderSettled, type RenderResult } from '../../../lib/renderer'; +import { renderers } from '../../../lib/renderers'; import { trackedObject } from '@ember/reactive/collections'; import { cached, tracked } from '@glimmer/tracking'; import Service, { service } from '@ember/service'; @@ -46,35 +46,34 @@ class RenderComponentTestCase extends AbstractStrictTestCase { return document.querySelector('#qunit-fixture')!; } - assertChange({ change, expect }: { change: () => void; expect: string }) { - run(() => change()); + async assertChange({ change, expect }: { change: () => void; expect: string }) { + change(); + await renderSettled(); assertHTML(expect); - this.assertStableRerender(); + await this.assertStableRerender(); } - renderComponent( + async renderComponent( component: object, options: { args?: Record; expect: string } | { classic: ClassicComponentShape } ) { let { owner } = this; - run(() => { - const result = renderComponent(component, { - owner, - args: 'args' in options ? options.args : {}, - env: { document: document, isInteractive: true, hasDOM: true }, - into: this.element, - }); - this.component = { - ...result, - rerender() { - // unused, but asserted against - }, - }; - registerDestructor(this, () => result.destroy()); + const result = renderComponent(component, { + owner, + args: 'args' in options ? options.args : {}, + env: { document: document, isInteractive: true, hasDOM: true }, + into: this.element, }); + this.component = { + ...result, + rerender() { + // unused, but asserted against + }, + }; + registerDestructor(this, () => result.destroy()); if ('expect' in options) { assertHTML(options.expect); @@ -82,62 +81,67 @@ class RenderComponentTestCase extends AbstractStrictTestCase { assertClassicComponentElement(options.classic); } - this.assertStableRerender(); + await this.assertStableRerender(); } } moduleFor( 'Strict Mode - RenderComponentTestCase', class extends RenderComponentTestCase { - afterEach() { + async afterEach() { if (this.component) { - runDestroy(this); + destroy(this); + await renderSettled(); } } - '@test destroy cleans up dom via destrying the test context'() { + async '@test destroy cleans up dom via destrying the test context'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ Foo }) }), templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } - '@test destroy of the owner cleans up dom via destrying the test context'() { + async '@test destroy of the owner cleans up dom via destrying the test context'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ Foo }) }), templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); - run(() => destroy(this.owner)); + destroy(this.owner); + await renderSettled(); assertHTML(''); } - '@test captureRenderTree includes the rendered components'(assert: QUnit['assert']) { + async '@test captureRenderTree includes the rendered components'(assert: QUnit['assert']) { let HelloWorld = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ HelloWorld }) }), templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); if (!ENV._DEBUG_RENDER_TREE) return; assert.deepEqual(renderTreeNames(this.owner), ['{ROOT}', 'HelloWorld']); } - '@test captureRenderTree includes components rendered with any owner'(assert: QUnit['assert']) { + async '@test captureRenderTree includes components rendered with any owner'( + assert: QUnit['assert'] + ) { let Owned = setComponentTemplate(precompileTemplate('owned'), templateOnly()); let Ownerless = setComponentTemplate(precompileTemplate('ownerless'), templateOnly()); let OwnedRoot = setComponentTemplate( @@ -153,10 +157,10 @@ moduleFor( let ownerlessElement = document.createElement('div'); this.element.append(ownedElement, ownerlessElement); - let results = run(() => [ + let results = [ renderComponent(OwnedRoot, { owner: this.owner, into: ownedElement }), renderComponent(OwnerlessRoot, { into: ownerlessElement }), - ]); + ]; assertHTML('
    owned
    ownerless
    '); @@ -164,17 +168,17 @@ moduleFor( assert.deepEqual(renderTreeNames(this.owner), ['{ROOT}', 'Owned', '{ROOT}', 'Ownerless']); } - run(() => { - for (let result of results) { - result.destroy(); - } - }); + for (let result of results) { + result.destroy(); + } + await renderSettled(); if (ENV._DEBUG_RENDER_TREE) { assert.deepEqual(renderTreeNames(this.owner), [], 'destroyed renders are gone'); } - run(() => destroy(this)); + destroy(this); + await renderSettled(); } } ); @@ -198,62 +202,59 @@ moduleFor( return document.querySelector('#qunit-fixture')!; } - '@test manually calling destroy cleans up the DOM'() { + async '@test manually calling destroy cleans up the DOM'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let owner = buildOwner({}); - let manualDestroy: () => void; - run(() => { - let result = renderComponent(Foo, { - owner, - into: this.element, - }); - manualDestroy = result.destroy; - this.component = { - ...result, - rerender() { - // unused, but asserted against - }, - }; + let result = renderComponent(Foo, { + owner, + into: this.element, }); + this.component = { + ...result, + rerender() { + // unused, but asserted against + }, + }; assertHTML('Hello, world!'); - this.assertStableRerender(); + await this.assertStableRerender(); - run(() => manualDestroy()); + result.destroy(); + await renderSettled(); assertHTML(''); - this.assertStableRerender(); + await this.assertStableRerender(); - run(() => destroy(owner)); + destroy(owner); + await renderSettled(); } - '@test destroying the owner cleans up the DOM'() { + async '@test destroying the owner cleans up the DOM'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let owner = buildOwner({}); - run(() => { - let result = renderComponent(Foo, { - owner, - into: this.element, - }); - this.component = { - ...result, - rerender() { - // unused, but asserted against - }, - }; + let result = renderComponent(Foo, { + owner, + into: this.element, }); + this.component = { + ...result, + rerender() { + // unused, but asserted against + }, + }; assertHTML('Hello, world!'); - this.assertStableRerender(); + await this.assertStableRerender(); - run(() => destroy(owner)); + destroy(owner); + await renderSettled(); assertHTML(''); - this.assertStableRerender(); + await this.assertStableRerender(); } } ); @@ -261,75 +262,77 @@ moduleFor( moduleFor( 'Strict Mode - renderComponent', class extends RenderComponentTestCase { - afterEach() { + async afterEach() { if (this.component) { - runDestroy(this); + destroy(this); + await renderSettled(); } } - '@test destroy cleans up dom via destroying the owner'() { + async '@test destroy cleans up dom via destroying the owner'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ Foo }) }), templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); - run(() => destroy(this.owner)); + destroy(this.owner); + await renderSettled(); assertHTML(''); } - '@test Can use a component in scope'() { + async '@test Can use a component in scope'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ Foo }) }), templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use a custom helper in scope (in append position)'() { + async '@test Can use a custom helper in scope (in append position)'() { let foo = defineSimpleHelper(() => 'Hello, world!'); let Root = setComponentTemplate( precompileTemplate('{{foo}}', { strictMode: true, scope: () => ({ foo }) }), templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use a custom modifier in scope'() { + async '@test Can use a custom modifier in scope'() { let foo = defineSimpleModifier((element) => (element.innerHTML = 'Hello, world!')); let Root = setComponentTemplate( precompileTemplate('
    ', { strictMode: true, scope: () => ({ foo }) }), templateOnly() ); - this.renderComponent(Root, { expect: '
    Hello, world!
    ' }); + await this.renderComponent(Root, { expect: '
    Hello, world!
    ' }); } - '@test Can shadow keywords (runtime)'() { + async '@test Can shadow keywords (runtime)'() { let each = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Bar = template('{{#each}}{{/each}}', { scope: () => ({ each }), }); - this.renderComponent(Bar, { expect: 'Hello, world!' }); + await this.renderComponent(Bar, { expect: 'Hello, world!' }); } - '@test Can shadow keywords (compile-time)'() { + async '@test Can shadow keywords (compile-time)'() { let each = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Bar = compileTimeTemplate('{{#each}}{{/each}}', { scope: () => ({ each }), }); - this.renderComponent(Bar, { expect: 'Hello, world!' }); + await this.renderComponent(Bar, { expect: 'Hello, world!' }); } - '@test Can use constant values in ambiguous helper/component position'() { + async '@test Can use constant values in ambiguous helper/component position'() { let value = 'Hello, world!'; let Root = setComponentTemplate( @@ -337,19 +340,19 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use inline if and unless in strict mode templates'() { + async '@test Can use inline if and unless in strict mode templates'() { let Root = setComponentTemplate( precompileTemplate('{{if true "foo" "bar"}}{{unless true "foo" "bar"}}'), templateOnly() ); - this.renderComponent(Root, { expect: 'foobar' }); + await this.renderComponent(Root, { expect: 'foobar' }); } - '@test multiple components have independent lifetimes'() { + async '@test multiple components have independent lifetimes'() { class State { @tracked showSecond = true; } @@ -363,51 +366,51 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!Hello, world!' }); - this.assertChange({ + await this.assertChange({ change: () => (state.showSecond = false), expect: 'Hello, world!', }); } - '@test Can use a dynamic component definition'() { + async '@test Can use a dynamic component definition'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate(''), - class extends GlimmerishComponent { + class extends GlimmerComponent { Foo = Foo; } ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use a dynamic component definition (curly)'() { + async '@test Can use a dynamic component definition (curly)'() { let Foo = setComponentTemplate(precompileTemplate('Hello, world!'), templateOnly()); let Root = setComponentTemplate( precompileTemplate('{{this.Foo}}'), - class extends GlimmerishComponent { + class extends GlimmerComponent { Foo = Foo; } ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use a dynamic helper definition'() { + async '@test Can use a dynamic helper definition'() { let foo = defineSimpleHelper(() => 'Hello, world!'); let Root = setComponentTemplate( precompileTemplate('{{this.foo}}'), - class extends GlimmerishComponent { + class extends GlimmerComponent { foo = foo; } ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use a curried dynamic helper'() { + async '@test Can use a curried dynamic helper'() { let foo = defineSimpleHelper((value) => value); let Foo = setComponentTemplate(precompileTemplate('{{@value}}'), templateOnly()); let Root = setComponentTemplate( @@ -418,10 +421,10 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { expect: 'Hello, world!' }); + await this.renderComponent(Root, { expect: 'Hello, world!' }); } - '@test Can use a curried dynamic modifier'() { + async '@test Can use a curried dynamic modifier'() { let foo = defineSimpleModifier((element, [text]) => (element.innerHTML = text)); let Foo = setComponentTemplate(precompileTemplate('
    '), templateOnly()); let Root = setComponentTemplate( @@ -432,22 +435,22 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { expect: '
    Hello, world!
    ' }); + await this.renderComponent(Root, { expect: '
    Hello, world!
    ' }); } - '@test when args are trackedObject, the rendered component response appropriately'() { + async '@test when args are trackedObject, the rendered component response appropriately'() { let args = trackedObject({ foo: 2 }); let Root = setComponentTemplate(precompileTemplate('{{@foo}}'), templateOnly()); - this.renderComponent(Root, { args, expect: '2' }); + await this.renderComponent(Root, { args, expect: '2' }); - this.assertChange({ + await this.assertChange({ change: () => args.foo++, expect: '3', }); } - '@skip when args are a custom tracked class, the rendered component response appropriately'() { + async '@skip when args are a custom tracked class, the rendered component response appropriately'() { class Args { @tracked foo = 2; } @@ -455,15 +458,15 @@ moduleFor( let Root = setComponentTemplate(precompileTemplate('{{@foo}}'), templateOnly()); // @ts-expect-error SAFETY: custom class is not currently supported as args, but would be nice to support? - this.renderComponent(Root, { args, expect: '2' }); + await this.renderComponent(Root, { args, expect: '2' }); - this.assertChange({ + await this.assertChange({ change: () => args.foo++, expect: '3', }); } - '@test a modifier can call renderComponent'() { + async '@test a modifier can call renderComponent'() { let render = defineSimpleModifier((element, [comp]) => { let result = renderComponent(comp, { into: element }); @@ -479,15 +482,87 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { expect: '
    hi there
    ' }); + await this.renderComponent(Root, { expect: '
    hi there
    ' }); + } + + async '@test destroying the result releases its root and renderer'(assert: QUnit['assert']) { + let render = defineSimpleModifier((element, [comp]) => { + let result = renderComponent(comp, { into: element }); + + return () => result.destroy(); + }); + + let Inner = setComponentTemplate(precompileTemplate('hi there'), templateOnly()); + + class State { + @tracked show = true; + } + let state = new State(); + + let Root = setComponentTemplate( + precompileTemplate(`{{#if state.show}}
    {{/if}}`, { + strictMode: true, + scope: () => ({ render, Inner, state }), + }), + templateOnly() + ); + + await this.renderComponent(Root, { expect: '
    hi there
    ' }); + + let baseline = renderers.length; + + for (let i = 0; i < 5; i++) { + state.show = false; + await renderSettled(); + assertHTML(''); + state.show = true; + await renderSettled(); + assertHTML('
    hi there
    '); + } + + assert.strictEqual( + renderers.length, + baseline, + 'renderers for destroyed renderComponent results are not retained' + ); + } + + async '@test destroying the result removes its root from a shared renderer'( + assert: QUnit['assert'] + ) { + let Inner = setComponentTemplate(precompileTemplate('hi there'), templateOnly()); + let { owner } = this; + let element = document.createElement('div'); + + let first = renderComponent(Inner, { owner, into: element }); + let renderer = renderers[renderers.length - 1]!; + + assert.strictEqual(renderer.state.roots.length, 1); + + for (let i = 0; i < 5; i++) { + let other = document.createElement('div'); + let result = renderComponent(Inner, { owner, into: other }); + result.destroy(); + await renderSettled(); + } + + assert.strictEqual(renderer.state.roots.length, 1, 'destroyed roots are not retained'); + + first.destroy(); + await renderSettled(); + + assert.false(renderers.includes(renderer), 'renderer with no roots is deregistered'); + + destroy(this); + await renderSettled(); } - '@test can render in to a detached element'() { + async '@test can render in to a detached element'() { let Inner = setComponentTemplate(precompileTemplate('hello there'), templateOnly()); let element = document.createElement('div'); let attach: () => void; - class _Root extends GlimmerishComponent { + class _Root extends GlimmerComponent { @tracked attached: Element | undefined; constructor(owner: any, args: any) { @@ -503,9 +578,9 @@ moduleFor( let Root = setComponentTemplate(precompileTemplate(`{{this.attached}}`), _Root); - this.renderComponent(Root, { expect: '' }); + await this.renderComponent(Root, { expect: '' }); - this.assertChange({ + await this.assertChange({ change: () => attach(), expect: `
    hello there
    `, }); @@ -515,11 +590,11 @@ moduleFor( * Test skipped because when an error occurs, * we mess up the cache used by renderComponent. */ - '@skip can *not* render in to a TextNode'(assert: Assert) { + async '@skip can *not* render in to a TextNode'(assert: Assert) { let Inner = setComponentTemplate(precompileTemplate('hello there'), templateOnly()); let element = document.createTextNode(''); - class _Root extends GlimmerishComponent { + class _Root extends GlimmerComponent { @tracked attached: Element | undefined; constructor(owner: any, args: any) { @@ -539,18 +614,18 @@ moduleFor( let Root = setComponentTemplate(precompileTemplate(``), _Root); - this.renderComponent(Root, { expect: '' }); + await this.renderComponent(Root, { expect: '' }); assert.verifySteps(['throw']); } - '@test replaces existing contents within the target element'() { + async '@test replaces existing contents within the target element'() { let Inner = setComponentTemplate(precompileTemplate('hello there'), templateOnly()); let element = document.createElement('div'); element.innerHTML = 'general kenobi'; let render: () => void; - class _Root extends GlimmerishComponent { + class _Root extends GlimmerComponent { constructor(owner: any, args: any) { super(owner, args); @@ -567,15 +642,15 @@ moduleFor( _Root ); - this.renderComponent(Root, { expect: '
    general kenobi
    ' }); + await this.renderComponent(Root, { expect: '
    general kenobi
    ' }); - this.assertChange({ + await this.assertChange({ change: () => render(), expect: `
    hello there
    `, }); } - [`@test renderComponent is eager, so it tracks with its parent`](assert: Assert) { + async [`@test renderComponent is eager, so it tracks with its parent`](assert: Assert) { let step = (...x: unknown[]) => assert.step(x.join(':')); let Inner = setComponentTemplate( @@ -584,7 +659,7 @@ moduleFor( ); let element = document.createElement('div'); - class _Root extends GlimmerishComponent { + class _Root extends GlimmerComponent { @tracked foo = 2; increment = () => this.foo++; @@ -616,10 +691,10 @@ moduleFor( _Root ); - this.renderComponent(Root, { expect: '
    2
    ' }); + await this.renderComponent(Root, { expect: '
    2
    ' }); assert.verifySteps(['render:root', 'foo:2']); - this.assertChange({ + await this.assertChange({ change: () => { this.element.querySelector('button')?.click(); }, @@ -641,14 +716,13 @@ moduleFor( assert.strictEqual(this.element.innerHTML, '
    3
    '); - run(() => { - destroy(this.owner); - }); + destroy(this.owner); + await renderSettled(); assert.strictEqual(this.element.innerHTML, ''); } - '@test multiple renderComponents share reactivity'() { + async '@test multiple renderComponents share reactivity'() { let args = trackedObject({ foo: 2 }); let InnerOne = setComponentTemplate(precompileTemplate('{{@foo}}'), templateOnly()); @@ -660,7 +734,7 @@ moduleFor( element1.setAttribute('data-one', ''); element2.setAttribute('data-two', ''); - class _Root extends GlimmerishComponent { + class _Root extends GlimmerComponent { constructor(owner: any, _args: any) { super(owner, _args); @@ -682,25 +756,27 @@ moduleFor( _Root ); - this.renderComponent(Root, { expect: '
    2
    2
    ' }); + await this.renderComponent(Root, { + expect: '
    2
    2
    ', + }); - this.assertChange({ + await this.assertChange({ change: () => args.foo++, expect: '
    3
    3
    ', }); } - '@test multiple renderComponents share service injection'() { + async '@test multiple renderComponents share service injection'() { class State extends Service { @tracked foo = 2; } this.owner.register('service:state', State); - class _One extends GlimmerishComponent { + class _One extends GlimmerComponent { @service state!: State; } - class _Two extends GlimmerishComponent { + class _Two extends GlimmerComponent { @service state!: State; } let InnerOne = setComponentTemplate(precompileTemplate('{{this.state.foo}}'), _One); @@ -712,7 +788,7 @@ moduleFor( element1.setAttribute('data-one', ''); element2.setAttribute('data-two', ''); - class _Root extends GlimmerishComponent { + class _Root extends GlimmerComponent { constructor(owner: any, _args: any) { super(owner, _args); @@ -734,17 +810,19 @@ moduleFor( _Root ); - this.renderComponent(Root, { expect: '
    2
    2
    ' }); + await this.renderComponent(Root, { + expect: '
    2
    2
    ', + }); let x = this.owner.lookup('service:state') as State; - this.assertChange({ + await this.assertChange({ change: () => x.foo++, expect: '
    3
    3
    ', }); } - '@test rendering multiple times to adjacent elements'() { + async '@test rendering multiple times to adjacent elements'() { let aHelper = (str: string) => str.toUpperCase(); let Child = setComponentTemplate( precompileTemplate(`Hi: {{aHelper "there"}}`, { @@ -754,7 +832,7 @@ moduleFor( templateOnly() ); let get = (id: string) => this.element.querySelector(id); - function render(Comp: GlimmerishComponent, id: string, owner: Owner) { + function render(Comp: GlimmerComponent, id: string, owner: Owner) { renderComponent(Comp, { into: get(`#${id}`)!, owner, @@ -777,18 +855,19 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { + await this.renderComponent(Root, { expect: [`
    a:Hi: THERE

    `, `
    b:Hi: THERE
    `, ``, ``].join( '\n' ), }); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } - '@test multiple calls to render in to the same element appear as siblings'() { + async '@test multiple calls to render in to the same element appear as siblings'() { let aHelper = (str: string) => str.toUpperCase(); let Child = setComponentTemplate( precompileTemplate(`Hi: {{aHelper "there"}}`, { @@ -798,7 +877,7 @@ moduleFor( templateOnly() ); let get = (id: string) => this.element.querySelector(id); - function render(Comp: GlimmerishComponent, id: string, owner: Owner) { + function render(Comp: GlimmerComponent, id: string, owner: Owner) { renderComponent(Comp, { into: get(`#${id}`)!, owner, @@ -817,10 +896,11 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { + await this.renderComponent(Root, { expect: `
    a:Hi: THEREa:Hi: THERE

    \n\n`, }); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } @@ -828,7 +908,7 @@ moduleFor( /** * NOTE: subsequent renders to the same element are prepended to the element's children */ - '@test multiple calls to render in to the same element appear as siblings and can be updated'() { + async '@test multiple calls to render in to the same element appear as siblings and can be updated'() { let aHelper = (str: string) => str.toUpperCase(); let dataA = trackedObject({ count: 1 }); let dataB = trackedObject({ count: -1 }); @@ -841,7 +921,7 @@ moduleFor( ); let get = (id: string) => this.element.querySelector(id); - function render(Comp: GlimmerishComponent, id: string, owner: Owner) { + function render(Comp: GlimmerComponent, id: string, owner: Owner) { renderComponent(Comp, { into: get(`#${id}`)!, owner, @@ -871,7 +951,7 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { + await this.renderComponent(Root, { expect: [ `
    `, `b:Hi: THERE:-1`, @@ -882,7 +962,7 @@ moduleFor( ].join('\n'), }); - this.assertChange({ + await this.assertChange({ change: () => dataA.count++, expect: [ `
    `, @@ -898,7 +978,7 @@ moduleFor( * ERROR in conflict with the NOTE on this test. * the elementns flip locations... which is kinda bonkers */ - this.assertChange({ + await this.assertChange({ change: () => dataB.count--, expect: [ `
    `, @@ -910,7 +990,8 @@ moduleFor( ].join('\n'), }); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } @@ -920,7 +1001,7 @@ moduleFor( let get = (id: string) => this.element.querySelector(id); let promises: Promise[] = []; - function render(Comp: GlimmerishComponent, id: string, owner: Owner) { + function render(Comp: GlimmerComponent, id: string, owner: Owner) { let promise = (async () => { await Promise.resolve(); let element = get(`#${id}`); @@ -952,7 +1033,7 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { + await this.renderComponent(Root, { expect: [`

    `, `
    `, ``, ``].join('\n'), }); @@ -960,7 +1041,8 @@ moduleFor( assertHTML([`
    a:Hi

    `, `
    b:Hi
    `, ``, ``].join('\n')); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } @@ -970,7 +1052,7 @@ moduleFor( moduleFor( 'Strict Mode <-> Loose Mode - renderComponent', class extends RenderComponentTestCase { - '@test incidentally invoked loose-mode components can still resolve helpers'() { + async '@test incidentally invoked loose-mode components can still resolve helpers'() { this.owner.register('helper:a-helper', (str: string) => str.toUpperCase()); let Loose = setComponentTemplate( precompileTemplate(`Hi: {{a-helper "there"}}`), @@ -981,9 +1063,10 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { expect: 'Hi: THERE' }); + await this.renderComponent(Root, { expect: 'Hi: THERE' }); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } @@ -999,14 +1082,14 @@ moduleFor( }, /but that value was not in scope: a-helper/); } - '@test rendering multiple times to adjacent elements'() { + async '@test rendering multiple times to adjacent elements'() { this.owner.register('helper:a-helper', (str: string) => str.toUpperCase()); let Loose = setComponentTemplate( precompileTemplate(`Hi: {{a-helper "there"}}`), templateOnly() ); let get = (id: string) => this.element.querySelector(id); - function render(Comp: GlimmerishComponent, id: string, owner: Owner) { + function render(Comp: GlimmerComponent, id: string, owner: Owner) { renderComponent(Comp, { into: get(`#${id}`)!, owner, @@ -1029,13 +1112,14 @@ moduleFor( templateOnly() ); - this.renderComponent(Root, { + await this.renderComponent(Root, { expect: [`
    a:Hi: THERE

    `, `
    b:Hi: THERE
    `, ``, ``].join( '\n' ), }); - run(() => destroy(this)); + destroy(this); + await renderSettled(); assertHTML(''); } @@ -1045,13 +1129,13 @@ moduleFor( moduleFor( 'Strict Mode - renderComponent - built ins', class extends RenderComponentTestCase { - '@test Can use Input'() { + async '@test Can use Input'() { let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ Input }) }), templateOnly() ); - this.renderComponent(Root, { + await this.renderComponent(Root, { classic: { tagName: 'input', attrs: { @@ -1062,13 +1146,13 @@ moduleFor( }); } - '@test Can use Textarea'() { + async '@test Can use Textarea'() { let Root = setComponentTemplate( precompileTemplate('