diff --git a/packages/@ember/-internals/glimmer/lib/renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer.ts index f2a216bd8a5..895f5580425 100644 --- a/packages/@ember/-internals/glimmer/lib/renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/renderer.ts @@ -6,20 +6,26 @@ import { guidFor } from '@ember/-internals/utils'; import { getViewElement, getViewId } from '@ember/-internals/views'; import { assert } from '@ember/debug'; import { _backburner, _getCurrentRunLoop } from '@ember/runloop'; -import { destroy } from '@glimmer/destroyable'; +import { + associateDestroyableChild, + destroy, + isDestroyed, + registerDestructor, +} from '@glimmer/destroyable'; import { DEBUG } from '@glimmer/env'; import type { Bounds, Cursor, DebugRenderTree, - DynamicScope as GlimmerDynamicScope, Environment, - RenderResult, + DynamicScope as GlimmerDynamicScope, + RenderResult as GlimmerRenderResult, Template, TemplateFactory, EvaluationContext, CurriedComponent, TreeBuilder, + ClassicResolver, } from '@glimmer/interfaces'; import type { Nullable } from '@ember/-internals/utility-types'; @@ -33,6 +39,7 @@ import { curry, EMPTY_POSITIONAL, inTransaction, + renderComponent as glimmerRenderComponent, renderMain, runtimeOptions, } from '@glimmer/runtime'; @@ -42,10 +49,13 @@ import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator'; import type { SimpleDocument, SimpleElement, SimpleNode } from '@simple-dom/interface'; import RSVP from 'rsvp'; import type Component from './component'; +import { hasDOM } from '../../browser-environment'; +import type ClassicComponent from './component'; import { BOUNDS } from './component-managers/curly'; import { createRootOutlet } from './component-managers/outlet'; import { RootComponentDefinition } from './component-managers/root'; import { EmberEnvironmentDelegate } from './environment'; +import { StrictResolver } from './renderer/strict-resolver'; import ResolverImpl from './resolver'; import type { OutletState } from './utils/outlet'; import OutletView from './views/outlet'; @@ -123,9 +133,62 @@ function errorLoopTransaction(fn: () => void) { } } -class RootState { +type RootState = ClassicRootState | ComponentRootState; + +class ComponentRootState { + readonly type = 'component'; + + #result: GlimmerRenderResult | undefined; + #render: () => void; + + constructor( + state: RendererState, + definition: object, + options: { into: Cursor; args?: Record } + ) { + 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); + + // override .render function after initial render + this.#render = errorLoopTransaction(() => result.rerender({ alwaysRevalidate: false })); + }); + } + + isFor(_component: ClassicComponent): boolean { + return false; + } + + render(): void { + this.#render(); + } + + destroy(): void { + destroy(this); + } + + get destroyed(): boolean { + return isDestroyed(this); + } + + get result(): GlimmerRenderResult | undefined { + return this.#result; + } +} + +class ClassicRootState { + readonly type = 'classic'; public id: string; - public result: RenderResult | undefined; + public result: GlimmerRenderResult | undefined; public destroyed: boolean; public render: () => void; readonly env: Environment; @@ -133,7 +196,7 @@ class RootState { constructor( public root: Component | OutletView, context: EvaluationContext, - owner: InternalOwner, + owner: object, template: Template, self: Reference, parentElement: SimpleElement, @@ -199,18 +262,18 @@ class RootState { } } -const renderers: Renderer[] = []; +const renderers: BaseRenderer[] = []; export function _resetRenderers() { renderers.length = 0; } -function register(renderer: Renderer): void { +function register(renderer: BaseRenderer): void { assert('Cannot register the same renderer twice', renderers.indexOf(renderer) === -1); renderers.push(renderer); } -function deregister(renderer: Renderer): void { +function deregister(renderer: BaseRenderer): void { let index = renderers.indexOf(renderer); assert('Cannot deregister unknown unregistered renderer', index !== -1); renderers.splice(index, 1); @@ -218,7 +281,7 @@ function deregister(renderer: Renderer): void { function loopBegin(): void { for (let renderer of renderers) { - renderer._scheduleRevalidate(); + renderer.rerender(); } } @@ -258,7 +321,7 @@ function resolveRenderPromise() { let loops = 0; function loopEnd() { for (let renderer of renderers) { - if (!renderer._isValid()) { + if (!renderer.isValid()) { if (loops > ENV._RERENDER_LOOP_LIMIT) { loops = 0; // TODO: do something better @@ -280,73 +343,379 @@ interface ViewRegistry { [viewId: string]: unknown; } -export class Renderer { - private _rootTemplate: Template; - private _viewRegistry: ViewRegistry; - private _roots: RootState[]; - private _removedRoots: RootState[]; - private _builder: IBuilder; - private _inRenderTransaction = false; +type Resolver = ClassicResolver | StrictResolver; - private _owner: InternalOwner; - private _context: EvaluationContext; +interface RendererData { + owner: object; + context: EvaluationContext; + builder: IBuilder; +} - private _lastRevision = -1; - private _destroyed = false; +export class RendererState { + static create(data: RendererData, renderer: BaseRenderer): RendererState { + const state = new RendererState(data, renderer); + associateDestroyableChild(renderer, state); + return state; + } - /** @internal */ - _isInteractive: boolean; + readonly #data: RendererData; + #lastRevision = -1; + #inRenderTransaction = false; + #destroyed = false; + #roots: RootState[] = []; + #removedRoots: RootState[] = []; - readonly _runtimeResolver: ResolverImpl; - readonly env: Environment; + private constructor(data: RendererData, renderer: BaseRenderer) { + this.#data = data; - static create(props: { _viewRegistry: any }): Renderer { - let { _viewRegistry } = props; - let owner = getOwner(props); - assert('Renderer is unexpectedly missing an owner', owner); - let document = owner.lookup('service:-document') as SimpleDocument; - let env = owner.lookup('-environment:main') as { - isInteractive: boolean; - hasDOM: boolean; + registerDestructor(this, () => { + this.clearAllRoots(renderer); + }); + } + + get debug() { + return { + roots: this.#roots, + inRenderTransaction: this.#inRenderTransaction, + isInteractive: this.isInteractive, }; - let rootTemplate = owner.lookup(P`template:-root`) as TemplateFactory; - let builder = owner.lookup('service:-dom-builder') as IBuilder; - return new this(owner, document, env, rootTemplate, _viewRegistry, builder); } + get roots() { + 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; + } + + renderRoot(root: RootState, renderer: BaseRenderer): RootState { + let roots = this.#roots; + + roots.push(root); + associateDestroyableChild(this, root); + + if (roots.length === 1) { + register(renderer); + } + + this.#renderRootsTransaction(renderer); + + 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; + } + + // used to prevent calling _renderRoots again (see above) + // while we are actively rendering roots + this.#inRenderTransaction = true; + + let completedWithoutError = false; + try { + this.renderRoots(renderer); + completedWithoutError = true; + } finally { + if (!completedWithoutError) { + this.#lastRevision = valueForTag(CURRENT_TAG); + } + this.#inRenderTransaction = false; + } + } + + renderRoots(renderer: BaseRenderer): void { + let roots = this.#roots; + let removedRoots = this.#removedRoots; + let initialRootsLength: number; + + do { + initialRootsLength = roots.length; + + inTransaction(this.context.env, () => { + // ensure that for the first iteration of the loop + // each root is processed + 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; + } + + root.render(); + } + + this.#lastRevision = valueForTag(CURRENT_TAG); + }); + } while (roots.length > initialRootsLength); + + // remove any roots that were destroyed during this transaction + while (removedRoots.length) { + let root = removedRoots.pop(); + + let rootIndex = roots.indexOf(root!); + roots.splice(rootIndex, 1); + } + + if (this.#roots.length === 0) { + deregister(renderer); + } + } + + scheduleRevalidate(renderer: BaseRenderer): void { + _backburner.scheduleOnce('render', this, this.revalidate, renderer); + } + + isValid(): boolean { + return ( + this.#destroyed || this.#roots.length === 0 || validateTag(CURRENT_TAG, this.#lastRevision) + ); + } + + revalidate(renderer: BaseRenderer): void { + if (this.isValid()) { + return; + } + this.#renderRootsTransaction(renderer); + } + + clearAllRoots(renderer: BaseRenderer): void { + let roots = this.#roots; + for (let root of roots) { + destroy(root); + } + + this.#removedRoots.length = 0; + this.#roots = []; + + // if roots were present before destroying + // deregister this renderer instance + if (roots.length) { + deregister(renderer); + } + } +} + +type IntoTarget = Cursor | Element | SimpleElement; + +/** + * The returned object from `renderComponent` + * @public + * @module @ember/renderer + */ +export interface RenderResult { + /** + * Destroys the render tree and removes all rendered content from the element rendered into + */ + destroy(): void; +} + +function intoTarget(into: IntoTarget): Cursor { + if ('element' in into) { + return into; + } else { + return { element: into as SimpleElement, nextSibling: null }; + } +} + +/** + * Render a component into DOM element. + * + * @method renderComponent + * @static + * @for @ember/renderer + * @param {Object} component The component to render. + * @param {Object} options + * @param {Element} options.into Where to render the component in to. + * @param {Object} [options.owner] Optionally specify the owner to use. This will be used for injections, and overall cleanup. + * @param {Object} [options.env] Optional renderer configuration + * @param {Object} [options.args] Optionally pass args in to the component. These may be reactive as long as it is an object or object-like + * @public + */ +export function renderComponent( + /** + * The component definition to render. + * + * Any component that has had its manager registered is valid. + * For the component-types that ship with ember, manager registration + * does not need to be worried about. + */ + component: object, + { + owner = {}, + env, + into, + args, + }: { + /** + * The element to render the component in to. + */ + into: IntoTarget; + + /** + * Optional owner. Defaults to `{}`, can be any object, but will need to implement the [Owner](https://api.emberjs.com/ember/release/classes/Owner) API for components within this render tree to access services. + */ + owner?: object; + /** + * 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; + }; + + /** + * These args get passed to the rendered component + * + * If your args are reactive, re-rendering will happen automatically. + * + */ + 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; + let renderer = BaseRenderer.strict(owner, document, { + ...env, + isInteractive: env?.isInteractive ?? true, + hasDOM: env && 'hasDOM' in env ? Boolean(env?.['hasDOM']) : true, + }); + + /** + * 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 existing = RENDER_CACHE.get(into); + existing?.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 && into instanceof Element) { + into.innerHTML = ''; + } + + let innerResult = renderer.render(component, { into, args }).result; + + let result = { + destroy() { + if (innerResult) { + destroy(innerResult); + } + }, + }; + + RENDER_CACHE.set(into, result); + + return result; +} + +const RENDER_CACHE = new WeakMap(); + +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 StrictResolver(), + clientBuilder + ); + } + + readonly state: RendererState; + constructor( - owner: InternalOwner, - document: SimpleDocument, + owner: object, envOptions: { isInteractive: boolean; hasDOM: boolean }, - rootTemplate: TemplateFactory, - viewRegistry: ViewRegistry, - builder = clientBuilder + document: SimpleDocument, + resolver: Resolver, + builder: IBuilder ) { - this._owner = owner; - this._rootTemplate = rootTemplate(owner); - this._viewRegistry = viewRegistry || owner.lookup('-view-registry:main'); - this._roots = []; - this._removedRoots = []; - this._builder = builder; - this._isInteractive = envOptions.isInteractive; - let sharedArtifacts = artifacts(); - // resolver is exposed for tests - let resolver = (this._runtimeResolver = new ResolverImpl()); - let env = new EmberEnvironmentDelegate(owner, envOptions.isInteractive); + /** + * 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); - - this._context = new EvaluationContextImpl( + let context = new EvaluationContextImpl( sharedArtifacts, (heap) => new RuntimeOpImpl(heap), options ); - this.env = this._context.env; + + this.state = RendererState.create( + { + owner, + context, + builder, + }, + this + ); } get debugRenderTree(): DebugRenderTree { - let { debugRenderTree } = this.env; + let { debugRenderTree } = this.state.env; assert( 'Attempted to access the DebugRenderTree, but it did not exist. Is the Ember Inspector open?', @@ -356,6 +725,80 @@ export class Renderer { return debugRenderTree; } + isValid(): boolean { + return this.state.isValid(); + } + + destroy() { + destroy(this); + } + + render( + component: object, + options: { into: IntoTarget; args?: Record } + ): RootState { + const root = new ComponentRootState(this.state, component, { + args: options.args, + into: intoTarget(options.into), + }); + return this.state.renderRoot(root, this); + } + + rerender(): void { + this.state.scheduleRevalidate(this); + } + + // render(component: Component, options: { into: Cursor; args?: Record }): void { + // this.state.renderRoot(component); + // } +} + +export class Renderer extends BaseRenderer { + static strict( + owner: object, + document: SimpleDocument | Document, + options: { isInteractive: boolean; hasDOM?: boolean } + ): BaseRenderer { + return new BaseRenderer( + owner, + { hasDOM: hasDOM, ...options }, + document as SimpleDocument, + new StrictResolver(), + clientBuilder + ); + } + + private _rootTemplate: Template; + private _viewRegistry: ViewRegistry; + + static create(props: { _viewRegistry: any }): Renderer { + let { _viewRegistry } = props; + let owner = getOwner(props); + assert('Renderer is unexpectedly missing an owner', owner); + let document = owner.lookup('service:-document') as SimpleDocument; + let env = owner.lookup('-environment:main') as { + isInteractive: boolean; + hasDOM: boolean; + }; + let rootTemplate = owner.lookup(P`template:-root`) as TemplateFactory; + let builder = owner.lookup('service:-dom-builder') as IBuilder; + return new this(owner, document, env, rootTemplate, _viewRegistry, builder); + } + + constructor( + owner: InternalOwner, + document: SimpleDocument, + env: { isInteractive: boolean; hasDOM: boolean }, + rootTemplate: TemplateFactory, + viewRegistry: ViewRegistry, + builder = clientBuilder, + resolver = new ResolverImpl() + ) { + super(owner, env, document, resolver, builder); + this._rootTemplate = rootTemplate(owner); + this._viewRegistry = viewRegistry || owner.lookup('-view-registry:main'); + } + // renderer HOOKS appendOutletView(view: OutletView, target: SimpleElement): void { @@ -394,94 +837,98 @@ export class Renderer { ); } - appendTo(view: Component, target: SimpleElement): void { + appendTo(view: ClassicComponent, target: SimpleElement): void { let definition = new RootComponentDefinition(view); this._appendDefinition( view, - curry(0 as CurriedComponent, definition, this._owner, null, true), + curry(0 as CurriedComponent, definition, this.state.owner, null, true), target ); } _appendDefinition( - root: OutletView | Component, + root: OutletView | ClassicComponent, definition: CurriedValue, target: SimpleElement ): void { let self = createConstRef(definition, 'this'); let dynamicScope = new DynamicScope(null, UNDEFINED_REFERENCE); - let rootState = new RootState( + let rootState = new ClassicRootState( root, - this._context, - this._owner, + this.state.context, + this.state.owner, this._rootTemplate, self, target, dynamicScope, - this._builder + this.state.builder ); - this._renderRoot(rootState); + this.state.renderRoot(rootState, this); } - rerender(): void { - this._scheduleRevalidate(); - } - - register(view: any): void { - let id = getViewId(view); - assert( - 'Attempted to register a view with an id already in use: ' + id, - !this._viewRegistry[id] - ); - this._viewRegistry[id] = view; - } - - unregister(view: any): void { - delete this._viewRegistry[getViewId(view)]; - } - - remove(view: Component): void { - view._transitionTo('destroying'); - - this.cleanupRootFor(view); - - if (this._isInteractive) { - view.trigger('didDestroyElement'); - } - } - - cleanupRootFor(view: unknown): void { + cleanupRootFor(component: ClassicComponent): void { // no need to cleanup roots if we have already been destroyed - if (this._destroyed) { + if (isDestroyed(this)) { return; } - let roots = this._roots; + let roots = this.state.roots; // traverse in reverse so we can remove items // without mucking up the index - let i = this._roots.length; + let i = roots.length; while (i--) { let root = roots[i]; assert('has root', root); - if (root.isFor(view)) { + if (root.type === 'classic' && root.isFor(component)) { root.destroy(); roots.splice(i, 1); } } } - destroy() { - if (this._destroyed) { - return; + remove(view: ClassicComponent): void { + view._transitionTo('destroying'); + + this.cleanupRootFor(view); + + if (this.state.isInteractive) { + view.trigger('didDestroyElement'); } - this._destroyed = true; - this._clearAllRoots(); } - getElement(view: View): Nullable { + get _roots() { + return this.state.debug.roots; + } + + get _inRenderTransaction() { + return this.state.debug.inRenderTransaction; + } + + get _isInteractive() { + return this.state.debug.isInteractive; + } + + get _context() { + return this.state.context; + } + + register(view: any): void { + let id = getViewId(view); + assert( + 'Attempted to register a view with an id already in use: ' + id, + !this._viewRegistry[id] + ); + this._viewRegistry[id] = view; + } + + unregister(view: any): void { + delete this._viewRegistry[getViewId(view)]; + } + + getElement(component: View): Nullable { if (this._isInteractive) { - return getViewElement(view); + return getViewElement(component); } else { throw new Error( 'Accessing `this.element` is not allowed in non-interactive environments (such as FastBoot).' @@ -489,12 +936,12 @@ export class Renderer { } } - getBounds(view: View): { + getBounds(component: View): { parentElement: SimpleElement; firstNode: SimpleNode; lastNode: SimpleNode; } { - let bounds: Bounds | null = view[BOUNDS]; + let bounds: Bounds | null = component[BOUNDS]; assert('object passed to getBounds must have the BOUNDS symbol as a property', bounds); @@ -504,125 +951,4 @@ export class Renderer { return { parentElement, firstNode, lastNode }; } - - createElement(tagName: string): SimpleElement { - return this.env.getAppendOperations().createElement(tagName); - } - - _renderRoot(root: RootState): void { - let { _roots: roots } = this; - - roots.push(root); - - if (roots.length === 1) { - register(this); - } - - this._renderRootsTransaction(); - } - - _renderRoots(): void { - let { _roots: roots, _removedRoots: removedRoots } = this; - let initialRootsLength: number; - - do { - initialRootsLength = roots.length; - - inTransaction(this.env, () => { - // ensure that for the first iteration of the loop - // each root is processed - 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; - } - - root.render(); - } - - this._lastRevision = valueForTag(CURRENT_TAG); - }); - } while (roots.length > initialRootsLength); - - // remove any roots that were destroyed during this transaction - while (removedRoots.length) { - let root = removedRoots.pop(); - - let rootIndex = roots.indexOf(root!); - roots.splice(rootIndex, 1); - } - - if (this._roots.length === 0) { - deregister(this); - } - } - - _renderRootsTransaction(): void { - if (this._inRenderTransaction) { - // currently rendering roots, a new root was added and will - // be processed by the existing _renderRoots invocation - return; - } - - // used to prevent calling _renderRoots again (see above) - // while we are actively rendering roots - this._inRenderTransaction = true; - - let completedWithoutError = false; - try { - this._renderRoots(); - completedWithoutError = true; - } finally { - if (!completedWithoutError) { - this._lastRevision = valueForTag(CURRENT_TAG); - } - this._inRenderTransaction = false; - } - } - - _clearAllRoots(): void { - let roots = this._roots; - for (let root of roots) { - root.destroy(); - } - - this._removedRoots.length = 0; - this._roots = []; - - // if roots were present before destroying - // deregister this renderer instance - if (roots.length) { - deregister(this); - } - } - - _scheduleRevalidate(): void { - _backburner.scheduleOnce('render', this, this._revalidate); - } - - _isValid(): boolean { - return ( - this._destroyed || this._roots.length === 0 || validateTag(CURRENT_TAG, this._lastRevision) - ); - } - - _revalidate(): void { - if (this._isValid()) { - return; - } - this._renderRootsTransaction(); - } } diff --git a/packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts b/packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts new file mode 100644 index 00000000000..a7a80b8022d --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts @@ -0,0 +1,38 @@ +import type { + InternalComponentManager, + Nullable, + ResolvedComponentDefinition, +} from '@glimmer/interfaces'; +import { BUILTIN_HELPERS, BUILTIN_KEYWORD_HELPERS } from '../resolver'; + +/////////// + +/** + * Resolution for non built ins is now handled by the vm as we are using strict mode + */ +export class StrictResolver { + lookupHelper(name: string, _owner: object): Nullable { + return BUILTIN_HELPERS[name] ?? null; + } + + lookupBuiltInHelper(name: string): Nullable { + return BUILTIN_KEYWORD_HELPERS[name] ?? null; + } + + lookupModifier(_name: string, _owner: object): Nullable { + return null; + } + + lookupComponent( + _name: string, + _owner: object + ): Nullable< + ResolvedComponentDefinition> + > { + return null; + } + + lookupBuiltInModifier(_name: string): Nullable { + return null; + } +} diff --git a/packages/@ember/-internals/glimmer/lib/resolver.ts b/packages/@ember/-internals/glimmer/lib/resolver.ts index af51ab9865d..e6517bc3e88 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -87,7 +87,7 @@ function lookupComponentPair(owner: InternalOwner, name: string): Nullable = { +export const BUILTIN_KEYWORD_HELPERS: Record = { mut, readonly, unbound, @@ -101,7 +101,7 @@ const BUILTIN_KEYWORD_HELPERS: Record = { '-in-el-null': inElementNullCheckHelper, }; -const BUILTIN_HELPERS: Record = { +export const BUILTIN_HELPERS: Record = { ...BUILTIN_KEYWORD_HELPERS, array, concat, 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 new file mode 100644 index 00000000000..2fb8bdffdb6 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts @@ -0,0 +1,591 @@ +import { + AbstractStrictTestCase, + assertClassicComponentElement, + assertHTML, + buildOwner, + clickElement, + defComponent, + defineComponent, + defineSimpleHelper, + defineSimpleModifier, + moduleFor, + type ClassicComponentShape, + runDestroy, +} from 'internal-test-helpers'; + +import { Input, Textarea } from '@ember/component'; +import { array, concat, fn, get, hash, on } from '@glimmer/runtime'; +import GlimmerishComponent from '../../utils/glimmerish-component'; + +import { run } from '@ember/runloop'; +import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; +import { renderComponent, type RenderResult } from '../../../lib/renderer'; +import { trackedObject } from '@ember/reactive'; +import { cached, tracked } from '@glimmer/tracking'; +import Service, { service } from '@ember/service'; +import type Owner from '@ember/owner'; + +class RenderComponentTestCase extends AbstractStrictTestCase { + component: (RenderResult & { rerender: () => void }) | undefined; + owner: Owner; + + constructor(assert: QUnit['assert']) { + super(assert); + + this.owner = buildOwner({}); + associateDestroyableChild(this, this.owner); + } + + get element() { + return document.querySelector('#qunit-fixture')!; + } + + assertChange({ change, expect }: { change: () => void; expect: string }) { + run(() => change()); + + assertHTML(expect); + + this.assertStableRerender(); + } + + 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()); + }); + + if ('expect' in options) { + assertHTML(options.expect); + } else { + assertClassicComponentElement(options.classic); + } + + this.assertStableRerender(); + } +} + +moduleFor( + 'Strict Mode - renderComponent', + class extends RenderComponentTestCase { + afterEach() { + if (this.component) { + // runDestroy(this.component); + // runDestroy(this.owner); + runDestroy(this); + } + } + + '@test Can use a component in scope'() { + let Foo = defComponent('Hello, world!'); + let Root = defComponent('', { scope: { Foo } }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use a custom helper in scope (in append position)'() { + let foo = defineSimpleHelper(() => 'Hello, world!'); + let Root = defComponent('{{foo}}', { scope: { foo } }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use a custom modifier in scope'() { + let foo = defineSimpleModifier((element) => (element.innerHTML = 'Hello, world!')); + let Root = defComponent('
', { scope: { foo } }); + + this.renderComponent(Root, { expect: '
Hello, world!
' }); + } + + '@test Can shadow keywords'() { + let ifComponent = defineComponent({}, 'Hello, world!'); + let Bar = defComponent('{{#if}}{{/if}}', { scope: { if: ifComponent } }); + + this.renderComponent(Bar, { expect: 'Hello, world!' }); + } + + '@test Can use constant values in ambiguous helper/component position'() { + let value = 'Hello, world!'; + + let Root = defComponent('{{value}}', { scope: { value } }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use inline if and unless in strict mode templates'() { + let Root = defComponent('{{if true "foo" "bar"}}{{unless true "foo" "bar"}}'); + + this.renderComponent(Root, { expect: 'foobar' }); + } + + '@test Can use a dynamic component definition'() { + let Foo = defComponent('Hello, world!'); + let Root = defComponent('', { + component: class extends GlimmerishComponent { + Foo = Foo; + }, + }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use a dynamic component definition (curly)'() { + let Foo = defComponent('Hello, world!'); + let Root = defComponent('{{this.Foo}}', { + component: class extends GlimmerishComponent { + Foo = Foo; + }, + }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use a dynamic helper definition'() { + let foo = defineSimpleHelper(() => 'Hello, world!'); + let Root = defComponent('{{this.foo}}', { + component: class extends GlimmerishComponent { + foo = foo; + }, + }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use a curried dynamic helper'() { + let foo = defineSimpleHelper((value) => value); + let Foo = defComponent('{{@value}}'); + let Root = defComponent('', { + scope: { Foo, foo }, + }); + + this.renderComponent(Root, { expect: 'Hello, world!' }); + } + + '@test Can use a curried dynamic modifier'() { + let foo = defineSimpleModifier((element, [text]) => (element.innerHTML = text)); + let Foo = defComponent('
'); + let Root = defComponent('', { + scope: { Foo, foo }, + }); + + this.renderComponent(Root, { expect: '
Hello, world!
' }); + } + + '@test when args are trackedObject, the rendered component response appropriately'() { + let args = trackedObject({ foo: 2 }); + let Root = defComponent('{{@foo}}', { + scope: {}, + }); + + this.renderComponent(Root, { args, expect: '2' }); + + this.assertChange({ + change: () => args.foo++, + expect: '3', + }); + } + + '@skip when args are a custom tracked class, the rendered component response appropriately'() { + class Args { + @tracked foo = 2; + } + let args = new Args(); + let Root = defComponent('{{@foo}}', { + scope: {}, + }); + + // @ts-expect-error SAFETY: custom class is not currently supported as args, but would be nice to support? + this.renderComponent(Root, { args, expect: '2' }); + + this.assertChange({ + change: () => args.foo++, + expect: '3', + }); + } + + '@test a modifier can call renderComponent'() { + let render = defineSimpleModifier((element, [comp]) => { + let result = renderComponent(comp, { into: element }); + + return () => result.destroy(); + }); + + let Inner = defComponent('hi there'); + let Root = defComponent(`
`, { scope: { render, Inner } }); + + this.renderComponent(Root, { expect: '
hi there
' }); + } + + '@test can render in to a detached element'() { + let Inner = defComponent('hello there'); + let element = document.createElement('div'); + let attach: () => void; + + class _Root extends GlimmerishComponent { + @tracked attached: Element | undefined; + + constructor(owner: any, args: any) { + super(owner, args); + + let result = renderComponent(Inner, { into: element }); + + attach = () => void (this.attached = element); + + registerDestructor(this, () => result.destroy()); + } + } + + let Root = defComponent(`{{this.attached}}`, { component: _Root }); + + this.renderComponent(Root, { expect: '' }); + + this.assertChange({ + change: () => attach(), + expect: `
hello there
`, + }); + } + + /** + * 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) { + let Inner = defComponent('hello there'); + let element = document.createTextNode(''); + + class _Root extends GlimmerishComponent { + @tracked attached: Element | undefined; + + constructor(owner: any, args: any) { + super(owner, args); + + assert.throws( + () => { + assert.step('throw'); + // @ts-expect-error deliberately not supported + renderComponent(Inner, { into: element }); + }, + /Cannot add children to a Text/, + 'throws an error about not being able to add children to TextNodes' + ); + } + } + + let Root = defComponent(``, { component: _Root }); + + this.renderComponent(Root, { expect: '' }); + assert.verifySteps(['throw']); + } + + '@test replaces existing contents within the target element'() { + let Inner = defComponent('hello there'); + let element = document.createElement('div'); + element.innerHTML = 'general kenobi'; + + let render: () => void; + + class _Root extends GlimmerishComponent { + constructor(owner: any, args: any) { + super(owner, args); + + render = () => { + let result = renderComponent(Inner, { into: element }); + + registerDestructor(this, () => result.destroy()); + }; + } + } + + let Root = defComponent(`{{element}}`, { scope: { element }, component: _Root }); + + this.renderComponent(Root, { expect: '
general kenobi
' }); + + this.assertChange({ + change: () => render(), + expect: `
hello there
`, + }); + } + + [`@test renderComponent is eager, so it tracks with its parent`](assert: Assert) { + let step = (...x: unknown[]) => assert.step(x.join(':')); + + let Inner = defComponent('{{@foo}} '); + + let element = document.createElement('div'); + class _Root extends GlimmerishComponent { + @tracked foo = 2; + increment = () => this.foo++; + + @cached + get sillyExampleToTieInToReactivity() { + step('render:root'); + + let self = this; + let result = renderComponent(Inner, { + into: element, + args: { + get foo() { + step('foo', self.foo); + return self.foo; + }, + increment: () => void self.increment(), + }, + }); + + registerDestructor(this, () => result.destroy()); + return ''; + } + } + let Root = defComponent(`{{element}}{{this.sillyExampleToTieInToReactivity}}`, { + scope: { element }, + component: _Root, + }); + + this.renderComponent(Root, { expect: '
2
' }); + assert.verifySteps(['render:root', 'foo:2']); + + this.assertChange({ + change: () => { + this.element.querySelector('button')?.click(); + }, + expect: `
3
`, + }); + + /** + * @see + * https://github.com/emberjs/rfcs/pull/1099/files#diff-2b962105b9083ca84579cdc957f27f49407440f3c5078083fa369ec18cc46da8R365 + * + * We could later add an option to not do this behavior + */ + assert.verifySteps( + [`render:root`, `foo:3`, `foo:3`], + `Destruction is async, so we get an extra 'foo:3' here, and then because our getter consumes foo (since renderComponent is eager), we dirty the cached getter, and re-run the getter, creating a new renderComponent call, overwriting the existing contents` + ); + } + + '@test multiple renderComponents share reactivity'() { + let args = trackedObject({ foo: 2 }); + + let InnerOne = defComponent('{{@foo}}'); + let InnerTwo = defComponent('{{@foo}}'); + + let element1 = document.createElement('div'); + let element2 = document.createElement('div'); + + element1.setAttribute('data-one', ''); + element2.setAttribute('data-two', ''); + + class _Root extends GlimmerishComponent { + constructor(owner: any, _args: any) { + super(owner, _args); + + let result1 = renderComponent(InnerOne, { into: element1, args }); + let result2 = renderComponent(InnerTwo, { into: element2, args }); + + registerDestructor(this, () => { + result1.destroy(); + result2.destroy(); + }); + } + } + + let Root = defComponent(`{{element1}}{{element2}}`, { + scope: { element1, element2 }, + component: _Root, + }); + + this.renderComponent(Root, { expect: '
2
2
' }); + + this.assertChange({ + change: () => args.foo++, + expect: '
3
3
', + }); + } + + '@test multiple renderComponents share service injection'() { + class State extends Service { + @tracked foo = 2; + } + + this.owner.register('service:state', State); + + class _One extends GlimmerishComponent { + @service state!: State; + } + class _Two extends GlimmerishComponent { + @service state!: State; + } + let InnerOne = defComponent('{{this.state.foo}}', { component: _One }); + let InnerTwo = defComponent('{{this.state.foo}}', { component: _Two }); + + let element1 = document.createElement('div'); + let element2 = document.createElement('div'); + + element1.setAttribute('data-one', ''); + element2.setAttribute('data-two', ''); + + class _Root extends GlimmerishComponent { + constructor(owner: any, _args: any) { + super(owner, _args); + + let result1 = renderComponent(InnerOne, { into: element1, owner }); + let result2 = renderComponent(InnerTwo, { into: element2, owner }); + + registerDestructor(this, () => { + result1.destroy(); + result2.destroy(); + }); + } + } + + let Root = defComponent(`{{element1}}{{element2}}`, { + scope: { element1, element2 }, + component: _Root, + }); + + this.renderComponent(Root, { expect: '
2
2
' }); + + let x = this.owner.lookup('service:state') as State; + + this.assertChange({ + change: () => x.foo++, + expect: '
3
3
', + }); + } + } +); + +moduleFor( + 'Strict Mode - renderComponent - built ins', + class extends RenderComponentTestCase { + '@test Can use Input'() { + let Root = defComponent('', { scope: { Input } }); + + this.renderComponent(Root, { + classic: { + tagName: 'input', + attrs: { + type: 'text', + class: 'ember-text-field ember-view', + }, + }, + }); + } + + '@test Can use Textarea'() { + let Root = defComponent('