From 8b909eb5756f410d2b4644b5c0c3b9be6efe4a50 Mon Sep 17 00:00:00 2001 From: Yehuda Katz Date: Mon, 21 Oct 2024 17:56:50 -0700 Subject: [PATCH 1/2] Initial implementation of renderComponent There's more in this commit than there should be, largely because this commit bundles a change to the plugins that fixes lexical scope bugs. I intend to separate those changes out into their own PR before attempting to merge this one. This PR also needs a flag for the new API. Finally this commit adds some new testing infrastructure to generalize the base render tests so it can be used with templates that are not registered into a container. This is useful more generally, and could be used in other places in the test suite in the future. --- .../-internals/glimmer/lib/environment.ts | 9 +- .../@ember/-internals/glimmer/lib/renderer.ts | 695 ++++++++++++------ .../glimmer/lib/renderer/strict-resolver.ts | 40 + .../@ember/-internals/glimmer/lib/resolver.ts | 4 +- .../components/render-component-test.ts | 284 +++++++ .../views/lib/mixins/view_support.ts | 3 +- .../lib/plugins/assert-against-attrs.ts | 47 +- .../plugins/assert-against-named-outlets.ts | 6 +- .../assert-input-helper-without-block.ts | 6 +- .../lib/plugins/transform-action-syntax.ts | 21 +- .../lib/plugins/transform-each-track-array.ts | 6 +- .../lib/plugins/transform-resolutions.ts | 2 +- .../transform-wrap-mount-and-outlet.ts | 6 +- .../lib/plugins/utils.ts | 14 +- packages/ember-template-compiler/lib/types.ts | 4 + packages/internal-test-helpers/index.ts | 12 +- .../lib/assert-helpers.ts | 16 + .../lib/component-helper.ts | 35 + .../lib/define-template-values.ts | 26 +- .../lib/element-helpers.ts | 106 +++ .../lib/ember-dev/namespaces.ts | 2 +- .../lib/event-helpers.ts | 15 + .../internal-test-helpers/lib/module-for.ts | 22 +- .../lib/test-cases/abstract.ts | 51 +- 24 files changed, 1114 insertions(+), 318 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts create mode 100644 packages/internal-test-helpers/lib/assert-helpers.ts create mode 100644 packages/internal-test-helpers/lib/component-helper.ts create mode 100644 packages/internal-test-helpers/lib/event-helpers.ts diff --git a/packages/@ember/-internals/glimmer/lib/environment.ts b/packages/@ember/-internals/glimmer/lib/environment.ts index ffa77980ad5..a2a21f849ad 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -1,11 +1,10 @@ import { ENV } from '@ember/-internals/environment'; -import { get, set, _getProp, _setProp } from '@ember/-internals/metal'; -import type { InternalOwner } from '@ember/-internals/owner'; +import { _getProp, _setProp, get, set } from '@ember/-internals/metal'; import { getDebugName } from '@ember/-internals/utils'; import { constructStyleDeprecationMessage } from '@ember/-internals/views'; -import { assert, deprecate, warn } from '@ember/debug'; import type { DeprecationOptions } from '@ember/debug'; -import { schedule, _backburner } from '@ember/runloop'; +import { assert, deprecate, warn } from '@ember/debug'; +import { _backburner, schedule } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; import setGlobalContext from '@glimmer/global-context'; import type { EnvironmentDelegate } from '@glimmer/runtime'; @@ -131,7 +130,7 @@ const VM_ASSERTION_OVERRIDES: { id: string; message: string }[] = []; export class EmberEnvironmentDelegate implements EnvironmentDelegate { public enableDebugTooling: boolean = ENV._DEBUG_RENDER_TREE; - constructor(public owner: InternalOwner, public isInteractive: boolean) {} + constructor(public owner: object, public isInteractive: boolean) {} onTransactionCommit(): void {} } diff --git a/packages/@ember/-internals/glimmer/lib/renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer.ts index 1c9c0e44469..be0816ad02c 100644 --- a/packages/@ember/-internals/glimmer/lib/renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/renderer.ts @@ -6,23 +6,24 @@ 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 } from '@glimmer/destroyable'; import { DEBUG } from '@glimmer/env'; import type { Bounds, CompileTimeCompilationContext, + CompileTimeResolver, Cursor, DebugRenderTree, - DynamicScope as GlimmerDynamicScope, ElementBuilder, Environment, + DynamicScope as GlimmerDynamicScope, RenderResult, RuntimeContext, + RuntimeResolver, Template, TemplateFactory, } from '@glimmer/interfaces'; -import { CurriedType } from '@glimmer/vm'; import type { Nullable } from '@ember/-internals/utility-types'; import { programCompilationContext } from '@glimmer/opcode-compiler'; import { artifacts, RuntimeOpImpl } from '@glimmer/program'; @@ -35,22 +36,27 @@ import { DOMChanges, DOMTreeConstruction, inTransaction, + renderComponent as glimmerRenderComponent, renderMain, runtimeContext, } from '@glimmer/runtime'; import { unwrapTemplate } from '@glimmer/util'; import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator'; +import { CurriedType } from '@glimmer/vm'; 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 { NodeDOMTreeConstruction } from './dom'; 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'; +import { registerDestructor } from '@ember/destroyable'; export type IBuilder = (env: Environment, cursor: Cursor) => ElementBuilder; @@ -120,17 +126,71 @@ function errorLoopTransaction(fn: () => void) { } } -class RootState { +type RootState = ClassicRootState | ComponentRootState; + +class ComponentRootState { + readonly type = 'component'; + + #result: RenderResult | undefined; + #render: () => void; + + constructor( + state: RendererState, + definition: object, + options: { into: Cursor; args?: Record } + ) { + this.#render = errorLoopTransaction(() => { + let iterator = glimmerRenderComponent( + state.runtime, + state.builder(state.env, options.into), + state.compilation, + 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(): RenderResult | undefined { + return this.#result; + } +} + +class ClassicRootState { + readonly type = 'classic'; public id: string; public result: RenderResult | undefined; public destroyed: boolean; public render: () => void; constructor( - public root: Component | OutletView, + public root: ClassicComponent | OutletView, public runtime: RuntimeContext, context: CompileTimeCompilationContext, - owner: InternalOwner, + owner: object, template: Template, self: Reference, parentElement: SimpleElement, @@ -200,18 +260,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); @@ -219,7 +279,7 @@ function deregister(renderer: Renderer): void { function loopBegin(): void { for (let renderer of renderers) { - renderer._scheduleRevalidate(); + renderer.rerender(); } } @@ -259,7 +319,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 @@ -281,69 +341,265 @@ 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 = RuntimeResolver & CompileTimeResolver; + +interface RendererData { + owner: object; + runtime: RuntimeContext; + compilation: CompileTimeCompilationContext; + builder: (env: Environment, cursor: Cursor) => ElementBuilder; + resolver: Resolver; + env: { + isInteractive: boolean; + hasDOM: boolean; + document: SimpleDocument; + }; +} - private _owner: InternalOwner; - private _context: CompileTimeCompilationContext; - private _runtime: RuntimeContext; +export class RendererState { + static create(owner: RendererData, renderer: BaseRenderer): RendererState { + const state = new RendererState(owner, renderer); + associateDestroyableChild(renderer, state); + return state; + } - private _lastRevision = -1; - private _destroyed = false; + readonly #data: RendererData; + #lastRevision = -1; + #inRenderTransaction = false; + #destroyed = false; + #roots: RootState[] = []; + #removedRoots: RootState[] = []; - /** @internal */ - _isInteractive: boolean; + private constructor(data: RendererData, renderer: BaseRenderer) { + this.#data = data; - readonly _runtimeResolver: ResolverImpl; + registerDestructor(this, () => { + this.clearAllRoots(renderer); + }); + } - 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; + get debug() { + return { + roots: this.#roots, + inRenderTransaction: this.#inRenderTransaction, + isInteractive: this.#data.env.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); } - constructor( - owner: InternalOwner, - document: SimpleDocument, - env: { isInteractive: boolean; hasDOM: boolean }, - rootTemplate: TemplateFactory, - viewRegistry: ViewRegistry, - builder = clientBuilder - ) { - this._owner = owner; - this._rootTemplate = rootTemplate(owner); - this._viewRegistry = viewRegistry || owner.lookup('-view-registry:main'); - this._roots = []; - this._removedRoots = []; - this._builder = builder; - this._isInteractive = env.isInteractive; + get roots() { + return this.#roots; + } - // resolver is exposed for tests - let resolver = (this._runtimeResolver = new ResolverImpl()); + get owner(): object { + return this.#data.owner; + } - let sharedArtifacts = artifacts(); + get runtime(): RuntimeContext { + return this.#data.runtime; + } - this._context = programCompilationContext( - sharedArtifacts, - resolver, - (heap) => new RuntimeOpImpl(heap) + get builder(): (env: Environment, cursor: Cursor) => ElementBuilder { + return this.#data.builder; + } + + get compilation(): CompileTimeCompilationContext { + return this.#data.compilation; + } + + get env(): Environment { + return this.runtime.env; + } + + get isInteractive(): boolean { + return this.#data.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; + let runtime = this.runtime; + + do { + initialRootsLength = roots.length; + + inTransaction(runtime.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; + +function intoTarget(into: IntoTarget): Cursor { + if ('element' in into) { + return into; + } else { + return { element: into as SimpleElement, nextSibling: null }; + } +} + +/** + * This function returns `undefined` if there was an error rendering the + * component. + * + * @fixme restructure this to return a result containing the error rather than + * undefined. + */ +export function renderComponent( + component: object, + { + owner, + env, + into, + args, + }: { + owner: object; + env: { document: SimpleDocument | Document; isInteractive: boolean; hasDOM?: boolean }; + into: IntoTarget; + args?: Record; + } +): RenderResult | undefined { + let renderer = BaseRenderer.strict(owner, env.document, env); + + return renderer.render(component, { into, args }).result; +} + +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: object, + env: { isInteractive: boolean; hasDOM: boolean }, + document: SimpleDocument, + resolver: Resolver, + builder: (env: Environment, cursor: Cursor) => ElementBuilder + ) { let runtimeEnvironmentDelegate = new EmberEnvironmentDelegate(owner, env.isInteractive); - this._runtime = runtimeContext( + let sharedArtifacts = artifacts(); + let runtime = runtimeContext( { appendOperations: env.hasDOM ? new DOMTreeConstruction(document) @@ -354,10 +610,26 @@ export class Renderer { sharedArtifacts, resolver ); + + this.state = RendererState.create( + { + owner, + runtime, + builder, + resolver, + compilation: programCompilationContext( + sharedArtifacts, + resolver, + (heap) => new RuntimeOpImpl(heap) + ), + env: { ...env, document: document }, + }, + this + ); } get debugRenderTree(): DebugRenderTree { - let { debugRenderTree } = this._runtime.env; + let { debugRenderTree } = this.state.env; assert( 'Attempted to access the DebugRenderTree, but it did not exist. Is the Ember Inspector open?', @@ -367,6 +639,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 { @@ -378,95 +724,99 @@ export class Renderer { ); } - appendTo(view: Component, target: SimpleElement): void { + appendTo(view: ClassicComponent, target: SimpleElement): void { let definition = new RootComponentDefinition(view); this._appendDefinition( view, - curry(CurriedType.Component, definition, this._owner, null, true), + curry(CurriedType.Component, 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._runtime, - this._context, - this._owner, + this.state.runtime, + this.state.compilation, + this.state.owner, this._rootTemplate, self, target, dynamicScope, - this._builder - ); - this._renderRoot(rootState); - } - - 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.state.builder ); - this._viewRegistry[id] = view; + this.state.renderRoot(rootState, this); } - 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.compilation; + } + + 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).' @@ -474,12 +824,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); @@ -489,125 +839,4 @@ export class Renderer { return { parentElement, firstNode, lastNode }; } - - createElement(tagName: string): SimpleElement { - return this._runtime.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, _runtime: runtime, _removedRoots: removedRoots } = this; - let initialRootsLength: number; - - do { - initialRootsLength = roots.length; - - inTransaction(runtime.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..69dc88483f3 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts @@ -0,0 +1,40 @@ +import type { + CompileTimeResolver as VMCompileTimeResolver, + InternalComponentManager, + Nullable, + ResolvedComponentDefinition, + RuntimeResolver as VMRuntimeResolver, +} 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 implements VMRuntimeResolver, VMCompileTimeResolver { + 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 3df207331b0..3017b89f9cc 100644 --- a/packages/@ember/-internals/glimmer/lib/resolver.ts +++ b/packages/@ember/-internals/glimmer/lib/resolver.ts @@ -120,7 +120,7 @@ function lookupComponentPair( } } -const BUILTIN_KEYWORD_HELPERS: Record = { +export const BUILTIN_KEYWORD_HELPERS: Record = { action, mut, readonly, @@ -135,7 +135,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..73c8b3d7f90 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts @@ -0,0 +1,284 @@ +import { + AbstractStrictTestCase, + assertClassicComponentElement, + assertHTML, + buildOwner, + clickElement, + defComponent, + defineComponent, + defineSimpleHelper, + defineSimpleModifier, + moduleFor, + type ClassicComponentShape, +} 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 } from '@glimmer/destroyable'; +import type { RenderResult } from '@glimmer/interfaces'; +import { renderComponent } from '../../../lib/renderer'; + +class RenderComponentTestCase extends AbstractStrictTestCase { + component: RenderResult | undefined; + owner: object; + + constructor(assert: QUnit['assert']) { + super(assert); + + this.owner = buildOwner({}); + associateDestroyableChild(this, this.owner); + } + + get element() { + return document.querySelector('#qunit-fixture')!; + } + + renderComponent( + component: object, + options: { expect: string } | { classic: ClassicComponentShape } + ) { + let { owner } = this; + + run(() => { + this.component = renderComponent(component, { + owner, + env: { document: document, isInteractive: true, hasDOM: true }, + into: this.element, + }); + if (this.component) { + associateDestroyableChild(this, this.component); + } + }); + + if ('expect' in options) { + assertHTML(options.expect); + } else { + assertClassicComponentElement(options.classic); + } + + this.assertStableRerender(); + } +} + +moduleFor( + 'Strict Mode - renderComponent', + class extends RenderComponentTestCase { + '@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!
' }); + } + } +); + +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('