From 179a515ef56a4b09b7033769aeb7713944f2c9e6 Mon Sep 17 00:00:00 2001 From: Yehuda Katz Date: Wed, 9 Oct 2024 17:10:14 -0700 Subject: [PATCH] Start implementing the runtime compiler --- .eslintrc.js | 10 +- demo/index.html | 23 + demo/package.json | 23 + demo/src/as-module.ts | 13 + demo/src/compiler.ts | 70 + demo/src/main.ts | 78 + demo/src/precompile.ts | 3 + demo/src/rewrite.ts | 100 + demo/src/utils/cell.ts | 20 + demo/tsconfig.json | 20 + demo/vite.config.mts | 22 + package.json | 5 +- .../-internals/glimmer/lib/environment.ts | 2 +- .../@ember/-internals/glimmer/lib/renderer.ts | 12 +- .../glimmer/lib/renderer/base-renderer.ts | 487 + .../glimmer/lib/renderer/strict-resolver.ts | 39 + packages/@ember/-internals/package.json | 10 +- packages/@ember/application/package.json | 2 +- packages/@ember/array/package.json | 2 +- packages/@ember/component/package.json | 2 +- packages/@ember/controller/package.json | 2 +- packages/@ember/debug/package.json | 2 +- packages/@ember/destroyable/package.json | 2 +- packages/@ember/engine/package.json | 2 +- packages/@ember/enumerable/package.json | 2 +- packages/@ember/helper/package.json | 2 +- packages/@ember/modifier/package.json | 2 +- packages/@ember/object/package.json | 2 +- packages/@ember/routing/package.json | 2 +- packages/@ember/service/package.json | 2 +- packages/@ember/template-compiler/index.ts | 14 + .../template-compiler/lib/compile-options.ts | 115 + .../lib/dasherize-component-name.ts | 22 + .../lib/plugins/assert-against-attrs.ts | 119 + .../plugins/assert-against-named-outlets.ts | 37 + .../assert-input-helper-without-block.ts | 27 + .../assert-reserved-named-arguments.ts | 47 + .../template-compiler/lib/plugins/index.ts | 54 + .../lib/plugins/transform-action-syntax.ts | 67 + .../plugins/transform-each-in-into-each.ts | 64 + .../lib/plugins/transform-each-track-array.ts | 60 + .../lib/plugins/transform-in-element.ts | 58 + ...form-quoted-bindings-into-just-bindings.ts | 46 + .../lib/plugins/transform-resolutions.ts | 199 + .../transform-wrap-mount-and-outlet.ts | 68 + .../template-compiler/lib/plugins/utils.ts | 55 + .../template-compiler/lib/precompile.ts | 24 + .../lib/system/calculate-location-display.ts | 28 + .../@ember/template-compiler/lib/template.ts | 30 + .../@ember/template-compiler/lib/types.ts | 59 + .../@ember/template-compiler/package.json | 16 + .../template-compiler/tests/template_test.ts | 23 + .../type-tests/template.test.ts | 6 + packages/@ember/template-factory/index.ts | 1 + packages/@ember/utils/package.json | 2 +- packages/@ember/version/package.json | 2 +- packages/ember-template-compiler/package.json | 2 +- packages/ember-testing/package.json | 2 +- packages/ember/package.json | 4 +- packages/internal-test-helpers/package.json | 4 +- pnpm-lock.yaml | 25163 ++++++++++------ pnpm-workspace.yaml | 12 +- tsconfig.json | 11 +- vite.config.mjs | 2 +- 64 files changed, 17569 insertions(+), 9837 deletions(-) create mode 100644 demo/index.html create mode 100644 demo/package.json create mode 100644 demo/src/as-module.ts create mode 100644 demo/src/compiler.ts create mode 100644 demo/src/main.ts create mode 100644 demo/src/precompile.ts create mode 100644 demo/src/rewrite.ts create mode 100644 demo/src/utils/cell.ts create mode 100644 demo/tsconfig.json create mode 100644 demo/vite.config.mts create mode 100644 packages/@ember/-internals/glimmer/lib/renderer/base-renderer.ts create mode 100644 packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts create mode 100644 packages/@ember/template-compiler/index.ts create mode 100644 packages/@ember/template-compiler/lib/compile-options.ts create mode 100644 packages/@ember/template-compiler/lib/dasherize-component-name.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/index.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-each-in-into-each.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-each-track-array.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-in-element.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-quoted-bindings-into-just-bindings.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-resolutions.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/transform-wrap-mount-and-outlet.ts create mode 100644 packages/@ember/template-compiler/lib/plugins/utils.ts create mode 100644 packages/@ember/template-compiler/lib/precompile.ts create mode 100644 packages/@ember/template-compiler/lib/system/calculate-location-display.ts create mode 100644 packages/@ember/template-compiler/lib/template.ts create mode 100644 packages/@ember/template-compiler/lib/types.ts create mode 100644 packages/@ember/template-compiler/package.json create mode 100644 packages/@ember/template-compiler/tests/template_test.ts create mode 100644 packages/@ember/template-compiler/type-tests/template.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index eb665f5eaaf..d23f96a043a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,7 +30,14 @@ module.exports = { settings: { 'import/core-modules': ['require', 'backburner', 'router', '@glimmer/interfaces'], + 'import/parsers': { + '@typescript-eslint/parser': ['.ts'], + }, 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: ['./tsconfig.json', './demo/tsconfig.json'], + }, node: { extensions: ['.js', '.ts', '.d.ts'], paths: [path.resolve('./packages/')], @@ -46,7 +53,7 @@ module.exports = { parserOptions: { sourceType: 'module', - project: './tsconfig.json', + project: ['./tsconfig.json', './demo/tsconfig.json'], tsconfigRootDir: __dirname, }, @@ -121,6 +128,7 @@ module.exports = { 'packages/@ember/*/tests/**/*.[jt]s', 'packages/@ember/-internals/*/tests/**/*.[jt]s', 'packages/internal-test-helpers/**/*.[jt]s', + 'demo/**/*.[jt]s', ], env: { qunit: true, diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 00000000000..2634d42c366 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,23 @@ + + + + + + + + +
+ + diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 00000000000..7c4d225fa05 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ember/embedded-demo", + "private": true, + "type": "module", + "dependencies": { + "@ember/-internals": "workspace:*", + "@ember/component": "workspace:*", + "@ember/template-compilation": "workspace:*", + "@ember/template-compiler": "workspace:*", + "@ember/template-factory": "workspace:*", + "@glimmer/tracking": "workspace:*", + "@swc/wasm-web": "^1.7.28", + "@swc/plugin-transform-imports": "^3.0.3", + "tracked-built-ins": "^3.3.0" + }, + "devDependencies": { + "@glimmer/compiler": "^0.92.4", + "@glimmer/syntax": "^0.92.3", + "content-tag": "^2.0.2", + "vite": "^5.4.8", + "vite-plugin-node-polyfills": "^0.22.0" + } +} diff --git a/demo/src/as-module.ts b/demo/src/as-module.ts new file mode 100644 index 00000000000..576e7d823c8 --- /dev/null +++ b/demo/src/as-module.ts @@ -0,0 +1,13 @@ +interface ModuleTag { + [Symbol.toStringTag]: 'Module'; +} +type ModuleObject = Record & ModuleTag; + +export async function asModule( + source: string + // { at, name = 'template.js' }: { at: { url: URL | string }; name?: string } +): Promise { + const blob = new Blob([source], { type: 'application/javascript' }); + + return import(URL.createObjectURL(blob)); +} diff --git a/demo/src/compiler.ts b/demo/src/compiler.ts new file mode 100644 index 00000000000..09648dab553 --- /dev/null +++ b/demo/src/compiler.ts @@ -0,0 +1,70 @@ +import type { ASTv1 } from '@glimmer/syntax'; +import initSwc, { transformSync, type Output } from '@swc/wasm-web'; +import type { PreprocessorOptions as ContentTagOptions } from 'content-tag'; +import { Preprocessor } from 'content-tag'; + +await initSwc({}); + +export class GjsCompiler { + readonly #contentTagPreprocessor = new Preprocessor(); + + #contentTag(source: string, options?: ContentTagOptions): string { + return this.#contentTagPreprocessor.process(source, options); + } + + compile = async (source: string, options?: ContentTagOptions): Promise<{ code: string }> => { + let output = this.#contentTag(source, { inline_source_map: true, ...options }); + + const result = transformSync(output, { + filename: options?.filename ?? 'unknown', + sourceMaps: options?.inline_source_map ? 'inline' : false, + inlineSourcesContent: Boolean(options?.inline_source_map), + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + legacyDecorator: true, + useDefineForClassFields: false, + }, + }, + }); + + // In real life, do something better than this + if (typeof result?.code !== 'string') { + throw new Error('Unable to compile'); + } + + result.code = result.code.replace( + /"moduleName":\s"[^"]+"/u, + `"moduleName": "${options?.filename ?? 'unknown'}"` + ); + + return Promise.resolve(result as Output); + }; +} + +const GJS_COMPILER = new GjsCompiler(); + +export const compile = GJS_COMPILER.compile; + +export interface PrinterOptions { + entityEncoding: ASTv1.EntityEncodingState; + + /** + * Used to override the mechanism of printing a given AST.Node. + * + * This will generally only be useful to source -> source codemods + * where you would like to specialize/override the way a given node is + * printed (e.g. you would like to preserve as much of the original + * formatting as possible). + * + * When the provided override returns undefined, the default built in printing + * will be done for the AST.Node. + * + * @param ast the ast node to be printed + * @param options the options specified during the print() invocation + */ + override?(ast: ASTv1.Node, options: PrinterOptions): void | string; +} diff --git a/demo/src/main.ts b/demo/src/main.ts new file mode 100644 index 00000000000..321a27c5158 --- /dev/null +++ b/demo/src/main.ts @@ -0,0 +1,78 @@ +import { ComponentRenderer } from '@ember/-internals/strict-renderer'; +import { compile } from './compiler'; +import { asModule } from './as-module'; +import { TrackedObject } from 'tracked-built-ins'; + +const owner = {}; +const renderer = new ComponentRenderer(owner, document, { + isInteractive: true, + hasDOM: true, +}); + +const componentModule = await compile(/*ts*/ ` + import { TrackedObject } from 'tracked-built-ins'; + import { tracked } from '@glimmer/tracking'; + + class Hello { + @tracked greeting: string; + } + + export const hello = new Hello() + + export const object = new TrackedObject({ + object: 'world', + }); + + class MyComponent { + + } + + + + const Paragraph = + + const Word = +`); + +const { + default: component, + hello, + object, +} = await asModule<{ + default: object; + hello: { greeting: string }; + object: { object: string }; +}>(componentModule.code); + +hello.greeting = 'hello'; +object.object = 'world'; +const args = new TrackedObject({ kind: 'great' }); + +const element = document.createElement('div'); +document.body.appendChild(element); + +renderer.render(component, { element, args }); + +await delay(1000); + +hello.greeting = 'goodbye'; + +await delay(1000); + +args.kind = 'cruel'; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/demo/src/precompile.ts b/demo/src/precompile.ts new file mode 100644 index 00000000000..3d6b9dabd41 --- /dev/null +++ b/demo/src/precompile.ts @@ -0,0 +1,3 @@ +import { precompile } from '@glimmer/compiler'; + +export const precompileTemplate = precompile; diff --git a/demo/src/rewrite.ts b/demo/src/rewrite.ts new file mode 100644 index 00000000000..2b4c5201a61 --- /dev/null +++ b/demo/src/rewrite.ts @@ -0,0 +1,100 @@ +import type * as Babel from '@babel/core'; +import type { NodePath } from '@babel/core'; +import type { ImportDeclaration } from '@babel/types'; + +export interface ImportRewrite { + to?: string; + specifier?: RewriteSpecifier | RewriteSpecifier[]; +} + +export interface RewriteSpecifier { + /** + * The name of the export to rename. The name `default` is + * legal here, and will apply to `import Default from "..."` + * syntax. + */ + from: string; + to: string; +} + +export type Rewrites = Record; + +export function rewrite( + t: (typeof Babel)['types'], + path: NodePath, + rewrites: Rewrites +) { + for (const [matchSource, rules] of Object.entries(rewrites)) { + for (const rule of intoArray(rules)) { + path = rewriteOne(t, matchSource, path, rule); + } + } + + return path; +} + +export function rewriteOne( + t: (typeof Babel)['types'], + matchSource: string, + path: NodePath, + rewrite: ImportRewrite +): NodePath { + const source = path.node.source.value; + + if (source !== matchSource) { + return path; + } + + if (rewrite.to) { + path.node.source = t.stringLiteral(rewrite.to); + } + + const renameSpecifiers = rewrite.specifier; + + if (!renameSpecifiers) { + return path; + } + + path.node.specifiers = path.node.specifiers.map((specifier) => { + for (const rewrite of intoArray(renameSpecifiers)) { + specifier = rewriteSpecifier(t, rewrite, specifier); + } + + return specifier; + }); + + return path; +} + +function rewriteSpecifier( + t: (typeof Babel)['types'], + rewrite: RewriteSpecifier, + specifier: ImportDeclaration['specifiers'][number] +) { + if (rewrite.from === 'default') { + if (t.isImportDefaultSpecifier(specifier)) { + // Intentionally keep the original name around so we don't have to adjust + // the scope. + return t.importSpecifier(specifier.local, t.identifier(rewrite.to)); + } + + // if the import didn't use default import syntax, we might still find a `default` + // named specifier, so don't return yet. + } + + if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported)) { + const importedName = specifier.imported.name; + + if (importedName === rewrite.from) { + // Intentionally keep the original name around so we don't have to adjust + // the scope. + return t.importSpecifier(specifier.local, t.identifier(rewrite.to)); + } + } + + return specifier; +} + +function intoArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} diff --git a/demo/src/utils/cell.ts b/demo/src/utils/cell.ts new file mode 100644 index 00000000000..f006cef2116 --- /dev/null +++ b/demo/src/utils/cell.ts @@ -0,0 +1,20 @@ +import { consumeTag, createTag, dirtyTag } from '@glimmer/validator'; + +export class Cell { + #tag = createTag(); + #value: T; + + constructor(value: T) { + this.#value = value; + } + + get current() { + consumeTag(this.#tag); + return this.#value; + } + + set current(value: T) { + this.#value = value; + dirtyTag(this.#tag); + } +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 00000000000..f03bf1fb644 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig/compiler-options.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": ".", + "rootDir": ".", + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler" + }, + "include": [ + "src/**/*.ts", + ], + "exclude": [ + "dist", + "node_modules", + "tmp", + "types" + ] +} diff --git a/demo/vite.config.mts b/demo/vite.config.mts new file mode 100644 index 00000000000..d0c998dd7e8 --- /dev/null +++ b/demo/vite.config.mts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + optimizeDeps: { + exclude: ['content-tag', '@swc/wasm-web'], + needsInterop: ['decorator-transforms'], + // noDiscovery: true, + // include: [ + // 'backburner.js', + // 'babel-plugin-ember-template-compilation', + // '@babel/standalone', + // '@babel/plugin-proposal-decorators', + // '@babel/plugin-transform-class-properties', + // 'babel-import-util', + // ], + // needsInterop: [ + // '@babel/plugin-proposal-decorators', + // '@babel/plugin-transform-class-properties', + // 'babel-import-util', + // ], + }, +}); diff --git a/package.json b/package.json index 00d12c22c9c..622fbd15bad 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@babel/core": "^7.24.4", "@ember/edition-utils": "^1.2.0", "@glimmer/compiler": "0.92.4", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/global-context": "0.92.3", "@glimmer/interfaces": "0.92.3", @@ -76,7 +76,7 @@ "@glimmer/owner": "0.92.3", "@glimmer/program": "0.92.4", "@glimmer/reference": "0.92.3", - "@glimmer/runtime": "0.92.4", + "@glimmer/runtime": "catalog:", "@glimmer/syntax": "0.92.3", "@glimmer/util": "0.92.3", "@glimmer/validator": "0.92.3", @@ -135,6 +135,7 @@ "eslint": "^8.53.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-node": "^0.3.7", + "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-disable-features": "^0.1.3", "eslint-plugin-ember-internal": "^3.0.0", "eslint-plugin-import": "^2.29.0", diff --git a/packages/@ember/-internals/glimmer/lib/environment.ts b/packages/@ember/-internals/glimmer/lib/environment.ts index ffa77980ad5..2bf17277fba 100644 --- a/packages/@ember/-internals/glimmer/lib/environment.ts +++ b/packages/@ember/-internals/glimmer/lib/environment.ts @@ -131,7 +131,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..02e83aee8d9 100644 --- a/packages/@ember/-internals/glimmer/lib/renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/renderer.ts @@ -51,6 +51,7 @@ import { EmberEnvironmentDelegate } from './environment'; import ResolverImpl from './resolver'; import type { OutletState } from './utils/outlet'; import OutletView from './views/outlet'; +import { deregister, register } from './renderer/base-renderer'; export type IBuilder = (env: Environment, cursor: Cursor) => ElementBuilder; @@ -206,17 +207,6 @@ export function _resetRenderers() { renderers.length = 0; } -function register(renderer: Renderer): void { - assert('Cannot register the same renderer twice', renderers.indexOf(renderer) === -1); - renderers.push(renderer); -} - -function deregister(renderer: Renderer): 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._scheduleRevalidate(); diff --git a/packages/@ember/-internals/glimmer/lib/renderer/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/renderer/base-renderer.ts new file mode 100644 index 00000000000..39e0550d5b7 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/renderer/base-renderer.ts @@ -0,0 +1,487 @@ +import { ENV } from '@ember/-internals/environment'; +import { getViewElement } from '@ember/-internals/views'; +import { assert } from '@ember/debug'; +import { _backburner, _getCurrentRunLoop } from '@ember/runloop'; +import { destroy } from '@glimmer/destroyable'; +import { DEBUG } from '@glimmer/env'; +import type { + Bounds, + CompileTimeCompilationContext, + Cursor, + DebugRenderTree, + ElementBuilder, + Environment, + DynamicScope as GlimmerDynamicScope, + RenderResult, + RuntimeContext, +} from '@glimmer/interfaces'; + +import type { Nullable } from '@ember/-internals/utility-types'; +import { programCompilationContext } from '@glimmer/opcode-compiler'; +import { artifacts, RuntimeOpImpl } from '@glimmer/program'; +import type { Reference } from '@glimmer/reference'; +import { UNDEFINED_REFERENCE } from '@glimmer/reference'; +import { + clientBuilder, + DOMChanges, + DOMTreeConstruction, + inTransaction, + renderComponent, + runtimeContext, +} from '@glimmer/runtime'; +import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator'; +import type { SimpleDocument, SimpleElement, SimpleNode } from '@simple-dom/interface'; +import RSVP from 'rsvp'; +import { BOUNDS } from '../component-managers/curly'; +import { NodeDOMTreeConstruction } from '../dom'; +import { EmberEnvironmentDelegate } from '../environment'; +import type { Renderer, View } from '../renderer'; +import type { OutletState } from '../utils/outlet'; +import { StrictResolver } from './strict-resolver'; + +export type IBuilder = (env: Environment, cursor: Cursor) => ElementBuilder; + +export class DynamicScope implements GlimmerDynamicScope { + constructor(public view: View | null, public outletState: Reference) {} + + child() { + return new DynamicScope(this.view, this.outletState); + } + + get(key: 'outletState'): Reference { + assert( + `Using \`-get-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`, + key === 'outletState' + ); + return this.outletState; + } + + set(key: 'outletState', value: Reference) { + assert( + `Using \`-with-dynamic-scope\` is only supported for \`outletState\` (you used \`${key}\`).`, + key === 'outletState' + ); + this.outletState = value; + return value; + } +} + +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. +function errorLoopTransaction(fn: () => void) { + if (DEBUG) { + return () => { + let didError = true; + + try { + fn(); + 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( + 'Attempted to rerender, but the Ember application has had an unrecoverable error occur during render. You should reload the application after fixing the cause of the error.' + ); + }; + } + } + }; + } else { + return fn; + } +} + +class RootState { + public result: RenderResult | undefined; + public destroyed: boolean; + public render: () => void; + + constructor( + public runtime: RuntimeContext, + context: CompileTimeCompilationContext, + owner: object, + component: object, + parentElement: SimpleElement, + dynamicScope: DynamicScope, + builder: IBuilder, + args: Record + ) { + this.result = undefined; + this.destroyed = false; + + this.render = errorLoopTransaction(() => { + let iterator = renderComponent( + runtime, + builder(runtime.env, { element: parentElement, nextSibling: null }), + context, + owner, + component, + args ?? {}, + dynamicScope + ); + + let result = (this.result = iterator.sync()); + + // override .render function after initial render + this.render = errorLoopTransaction(() => result.rerender({ alwaysRevalidate: false })); + }); + } + + destroy() { + let { + result, + runtime: { env }, + } = this; + + this.destroyed = true; + + this.runtime = undefined as any; + this.result = undefined; + this.render = undefined as any; + + if (result !== undefined) { + /* + Handles these scenarios: + + * When roots are removed during standard rendering process, a transaction exists already + `.begin()` / `.commit()` are not needed. + * When roots are being destroyed manually (`component.append(); component.destroy() case), no + transaction exists already. + * When roots are being destroyed during `Renderer#destroy`, no transaction exists + + */ + + inTransaction(env, () => destroy(result!)); + } + } +} + +const renderers: (Renderer | ComponentRenderer)[] = []; + +export function _resetRenderers() { + renderers.length = 0; +} + +export function register(renderer: Renderer | ComponentRenderer): void { + assert('Cannot register the same renderer twice', renderers.indexOf(renderer) === -1); + renderers.push(renderer); +} + +export function deregister(renderer: Renderer | ComponentRenderer): 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._scheduleRevalidate(); + } +} + +let renderSettledDeferred: RSVP.Deferred | 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) { + renderSettledDeferred = RSVP.defer(); + // 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(); +} + +_backburner.on('begin', loopBegin); +_backburner.on('end', loopEnd); + +export class ComponentRenderer { + private _roots: RootState[]; + private _removedRoots: RootState[]; + private _builder: IBuilder; + private _inRenderTransaction = false; + + private _owner: object; + private _context: CompileTimeCompilationContext; + private _runtime: RuntimeContext; + + private _lastRevision = -1; + private _destroyed = false; + + /** @internal */ + _isInteractive: boolean; + + readonly _runtimeResolver: StrictResolver; + + constructor( + owner: object, + document: SimpleDocument | Document, + env: { isInteractive: boolean; hasDOM: boolean }, + builder = clientBuilder + ) { + this._owner = owner; + this._roots = []; + this._removedRoots = []; + this._builder = builder; + this._isInteractive = env.isInteractive; + + // resolver is exposed for tests + let resolver = (this._runtimeResolver = new StrictResolver()); + + let sharedArtifacts = artifacts(); + + this._context = programCompilationContext( + sharedArtifacts, + resolver, + (heap) => new RuntimeOpImpl(heap) + ); + + let runtimeEnvironmentDelegate = new EmberEnvironmentDelegate(owner, env.isInteractive); + this._runtime = runtimeContext( + { + appendOperations: env.hasDOM + ? new DOMTreeConstruction(document as SimpleDocument) + : new NodeDOMTreeConstruction(document as SimpleDocument), + updateOperations: new DOMChanges(document as SimpleDocument), + }, + runtimeEnvironmentDelegate, + sharedArtifacts, + resolver + ); + } + + get debugRenderTree(): DebugRenderTree { + let { debugRenderTree } = this._runtime.env; + + assert( + 'Attempted to access the DebugRenderTree, but it did not exist. Is the Ember Inspector open?', + debugRenderTree + ); + + return debugRenderTree; + } + + // renderer HOOKS + + render( + component: object, + options: { element: Element | SimpleElement; args?: Record } + ): void { + let dynamicScope = new DynamicScope(null, UNDEFINED_REFERENCE); + + let rootState = new RootState( + this._runtime, + this._context, + this._owner, + component, + options.element as SimpleElement, + dynamicScope, + this._builder, + options.args ?? {} + ); + this._renderRoot(rootState); + } + + rerender(): void { + this._scheduleRevalidate(); + } + + destroy() { + if (this._destroyed) { + return; + } + this._destroyed = true; + this._clearAllRoots(); + } + + getElement(view: View): Nullable { + if (this._isInteractive) { + return getViewElement(view); + } else { + throw new Error( + 'Accessing `this.element` is not allowed in non-interactive environments (such as FastBoot).' + ); + } + } + + getBounds(view: View): { + parentElement: SimpleElement; + firstNode: SimpleNode; + lastNode: SimpleNode; + } { + let bounds: Bounds | null = view[BOUNDS]; + + assert('object passed to getBounds must have the BOUNDS symbol as a property', bounds); + + let parentElement = bounds.parentElement(); + let firstNode = bounds.firstNode(); + let lastNode = bounds.lastNode(); + + 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..42c387b44ce --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/renderer/strict-resolver.ts @@ -0,0 +1,39 @@ +import type { + CompileTimeResolver as VMCompileTimeResolver, + InternalComponentManager, + Nullable, + ResolvedComponentDefinition, + RuntimeResolver as VMRuntimeResolver, +} from '@glimmer/interfaces'; + +/////////// + +/** + * 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 null; + } + + lookupModifier(_name: string, _owner: object): Nullable { + return null; + } + + lookupComponent( + _name: string, + _owner: object + ): Nullable< + ResolvedComponentDefinition> + > { + return null; + } + + lookupBuiltInHelper(_name: string): Nullable { + return null; + } + + lookupBuiltInModifier(_name: string): Nullable { + return null; + } +} diff --git a/packages/@ember/-internals/package.json b/packages/@ember/-internals/package.json index 41028c3e3c2..9d2ba31137b 100644 --- a/packages/@ember/-internals/package.json +++ b/packages/@ember/-internals/package.json @@ -6,12 +6,16 @@ "exports": { "./browser-environment": "./browser-environment/index.ts", "./container": "./container/index.ts", + "./deprecations": "./deprecations/index.ts", "./environment": "./environment/index.ts", "./error-handling": "./error-handling/index.ts", "./glimmer": "./glimmer/index.ts", + "./glimmer-component": "./glimmer/lib/component.ts", + "./managers": "./glimmer/lib/utils/managers.ts", "./meta": "./meta/index.ts", "./metal": "./metal/index.ts", "./owner": "./owner/index.ts", + "./strict-renderer": "./glimmer/lib/renderer/base-renderer.ts", "./routing": "./routing/index.ts", "./runtime": "./runtime/index.ts", "./string": "./string/index.ts", @@ -38,10 +42,12 @@ "@ember/runloop": "workspace:*", "@ember/service": "workspace:*", "@ember/template-factory": "workspace:*", + "@ember/template-compilation": "workspace:*", "@ember/utils": "workspace:*", + "@ember/version": "workspace:*", "@glimmer/compiler": "0.92.4", "@glimmer/component": "^1.1.2", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/global-context": "0.92.3", "@glimmer/interfaces": "0.92.3", @@ -51,7 +57,7 @@ "@glimmer/owner": "0.92.3", "@glimmer/program": "0.92.4", "@glimmer/reference": "0.92.3", - "@glimmer/runtime": "0.92.4", + "@glimmer/runtime": "catalog:", "@glimmer/syntax": "0.92.3", "@glimmer/util": "0.92.3", "@glimmer/validator": "0.92.3", diff --git a/packages/@ember/application/package.json b/packages/@ember/application/package.json index 5936dc7297b..f549cc2fd55 100644 --- a/packages/@ember/application/package.json +++ b/packages/@ember/application/package.json @@ -19,7 +19,7 @@ "@ember/runloop": "workspace:*", "@ember/service": "workspace:*", "@ember/utils": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/owner": "0.92.3", "@glimmer/util": "0.92.3", diff --git a/packages/@ember/array/package.json b/packages/@ember/array/package.json index 7e2271cd9d8..62d7b7a5d56 100644 --- a/packages/@ember/array/package.json +++ b/packages/@ember/array/package.json @@ -17,7 +17,7 @@ "@ember/object": "workspace:*", "@ember/runloop": "workspace:*", "@ember/utils": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/manager": "0.92.4", "@glimmer/owner": "0.92.3", diff --git a/packages/@ember/component/package.json b/packages/@ember/component/package.json index f8998daa04e..c82b3a270a4 100644 --- a/packages/@ember/component/package.json +++ b/packages/@ember/component/package.json @@ -10,7 +10,7 @@ "dependencies": { "@ember/-internals": "workspace:*", "@glimmer/manager": "0.92.4", - "@glimmer/runtime": "0.92.4", + "@glimmer/runtime": "catalog:", "expect-type": "^0.15.0" } } diff --git a/packages/@ember/controller/package.json b/packages/@ember/controller/package.json index aa504bd5558..a3ec1f456ba 100644 --- a/packages/@ember/controller/package.json +++ b/packages/@ember/controller/package.json @@ -11,7 +11,7 @@ "@ember/debug": "workspace:*", "@ember/object": "workspace:*", "@ember/service": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/owner": "0.92.3", "@glimmer/util": "0.92.3", diff --git a/packages/@ember/debug/package.json b/packages/@ember/debug/package.json index 989be8e3219..ff526e400e0 100644 --- a/packages/@ember/debug/package.json +++ b/packages/@ember/debug/package.json @@ -18,7 +18,7 @@ "@ember/routing": "workspace:*", "@ember/runloop": "workspace:*", "@ember/utils": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/manager": "0.92.4", "@glimmer/owner": "0.92.3", diff --git a/packages/@ember/destroyable/package.json b/packages/@ember/destroyable/package.json index 75ec06f9079..16938991763 100644 --- a/packages/@ember/destroyable/package.json +++ b/packages/@ember/destroyable/package.json @@ -6,7 +6,7 @@ ".": "./index.ts" }, "dependencies": { - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "expect-type": "^0.15.0" } } diff --git a/packages/@ember/engine/package.json b/packages/@ember/engine/package.json index 550c9330356..ac83a301287 100644 --- a/packages/@ember/engine/package.json +++ b/packages/@ember/engine/package.json @@ -19,7 +19,7 @@ "@ember/runloop": "workspace:*", "@ember/service": "workspace:*", "@ember/utils": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/owner": "0.92.3", "@glimmer/util": "0.92.3", diff --git a/packages/@ember/enumerable/package.json b/packages/@ember/enumerable/package.json index 591e0d5571a..b49897df8b1 100644 --- a/packages/@ember/enumerable/package.json +++ b/packages/@ember/enumerable/package.json @@ -11,7 +11,7 @@ "@ember/array": "workspace:*", "@ember/debug": "workspace:*", "@ember/object": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/owner": "0.92.3", "@glimmer/util": "0.92.3", diff --git a/packages/@ember/helper/package.json b/packages/@ember/helper/package.json index 3a7a1b5444c..526cf21df97 100644 --- a/packages/@ember/helper/package.json +++ b/packages/@ember/helper/package.json @@ -9,7 +9,7 @@ "@ember/-internals": "workspace:*", "@ember/component": "workspace:*", "@glimmer/manager": "0.92.4", - "@glimmer/runtime": "0.92.4", + "@glimmer/runtime": "catalog:", "expect-type": "^0.15.0" } } diff --git a/packages/@ember/modifier/package.json b/packages/@ember/modifier/package.json index 34a977aa6ae..52933d1d5e0 100644 --- a/packages/@ember/modifier/package.json +++ b/packages/@ember/modifier/package.json @@ -9,6 +9,6 @@ "dependencies": { "@ember/-internals": "workspace:*", "@glimmer/manager": "0.92.4", - "@glimmer/runtime": "0.92.4" + "@glimmer/runtime": "catalog:" } } diff --git a/packages/@ember/object/package.json b/packages/@ember/object/package.json index 77553fadfe4..0554cfc0a16 100644 --- a/packages/@ember/object/package.json +++ b/packages/@ember/object/package.json @@ -26,7 +26,7 @@ "@ember/runloop": "workspace:*", "@ember/service": "workspace:*", "@ember/utils": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/manager": "0.92.4", "@glimmer/owner": "0.92.3", diff --git a/packages/@ember/routing/package.json b/packages/@ember/routing/package.json index b77a4f57bab..660e21c0dba 100644 --- a/packages/@ember/routing/package.json +++ b/packages/@ember/routing/package.json @@ -25,7 +25,7 @@ "@ember/runloop": "workspace:*", "@ember/service": "workspace:*", "@ember/utils": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/manager": "0.92.4", "@glimmer/owner": "0.92.3", diff --git a/packages/@ember/service/package.json b/packages/@ember/service/package.json index 85f148cc781..459d21436f4 100644 --- a/packages/@ember/service/package.json +++ b/packages/@ember/service/package.json @@ -10,7 +10,7 @@ "@ember/array": "workspace:*", "@ember/debug": "workspace:*", "@ember/object": "workspace:*", - "@glimmer/destroyable": "0.92.3", + "@glimmer/destroyable": "catalog:", "@glimmer/env": "^0.1.7", "@glimmer/owner": "0.92.3", "@glimmer/util": "0.92.3", diff --git a/packages/@ember/template-compiler/index.ts b/packages/@ember/template-compiler/index.ts new file mode 100644 index 00000000000..1a1cd0b2019 --- /dev/null +++ b/packages/@ember/template-compiler/index.ts @@ -0,0 +1,14 @@ +// The main entrypoint of ember-template-compiler is the fairly-crufty +// backward-compatible API. In contrast, this is the subset of that that's +// actually used by babel-plugin-ember-template-compilation. +// +// This module exists so that ember-source can build itself -- the +// ember-template-compiler.js bundle it an output of the build, but the build +// needs to compile templates. Unlike the full ./index.ts, this module can be +// directly evaluted in node because it doesn't try to pull in the whole kitchen +// sink. +export { default as precompile } from './lib/precompile'; +export { buildCompileOptions as _buildCompileOptions } from './lib/compile-options'; +export { preprocess as _preprocess, print as _print } from '@glimmer/syntax'; +export { template } from './lib/template'; +export type { EmberPrecompileOptions } from './lib/types'; diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts new file mode 100644 index 00000000000..c9a09f28d42 --- /dev/null +++ b/packages/@ember/template-compiler/lib/compile-options.ts @@ -0,0 +1,115 @@ +import { assert } from '@ember/debug'; +import { + RESOLUTION_MODE_TRANSFORMS, + STRICT_MODE_KEYWORDS, + STRICT_MODE_TRANSFORMS, +} from './plugins/index'; +import type { EmberPrecompileOptions, PluginFunc } from './types'; +import COMPONENT_NAME_SIMPLE_DASHERIZE_CACHE from './dasherize-component-name'; + +let USER_PLUGINS: PluginFunc[] = []; + +function malformedComponentLookup(string: string) { + return string.indexOf('::') === -1 && string.indexOf(':') > -1; +} + +export function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions { + let moduleName = _options.moduleName; + + let options: EmberPrecompileOptions & Partial = { + meta: {}, + isProduction: false, + plugins: { ast: [] }, + ..._options, + moduleName, + customizeComponentName(tagname: string): string { + assert( + `You tried to invoke a component named <${tagname} /> in "${ + moduleName ?? '[NO MODULE]' + }", but that is not a valid name for a component. Did you mean to use the "::" syntax for nested components?`, + !malformedComponentLookup(tagname) + ); + + return COMPONENT_NAME_SIMPLE_DASHERIZE_CACHE.get(tagname); + }, + }; + + if ('eval' in options) { + const localScopeEvaluator = options.eval as (value: string) => unknown; + const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)(); + + options.lexicalScope = (variable: string) => { + if (inScope(variable, localScopeEvaluator)) { + return !inScope(variable, globalScopeEvaluator); + } + + return false; + }; + + delete options.eval; + } + + if ('locals' in options && !options.locals) { + // Glimmer's precompile options declare `locals` like: + // locals?: string[] + // but many in-use versions of babel-plugin-htmlbars-inline-precompile will + // set locals to `null`. This used to work but only because glimmer was + // ignoring locals for non-strict templates, and now it supports that case. + delete options.locals; + } + + // move `moduleName` into `meta` property + if (options.moduleName) { + let meta = options.meta; + assert('has meta', meta); // We just set it + meta.moduleName = options.moduleName; + } + + if (options.strictMode) { + options.keywords = STRICT_MODE_KEYWORDS; + } + + return options; +} + +export function transformsFor(options: EmberPrecompileOptions): readonly PluginFunc[] { + return options.strictMode ? STRICT_MODE_TRANSFORMS : RESOLUTION_MODE_TRANSFORMS; +} + +export default function compileOptions( + _options: Partial = {} +): EmberPrecompileOptions { + let options = buildCompileOptions(_options); + let builtInPlugins = transformsFor(options); + + if (!_options.plugins) { + options.plugins = { ast: [...USER_PLUGINS, ...builtInPlugins] }; + } else { + let potententialPugins = [...USER_PLUGINS, ...builtInPlugins]; + assert('expected plugins', options.plugins); + let pluginsToAdd = potententialPugins.filter((plugin) => { + assert('expected plugins', options.plugins); + return options.plugins.ast.indexOf(plugin) === -1; + }); + options.plugins.ast = [...options.plugins.ast, ...pluginsToAdd]; + } + + return options; +} + +type Evaluator = (value: string) => unknown; + +function inScope(variable: string, evaluator: Evaluator): boolean { + try { + return evaluator(`typeof ${variable} !== "undefined"`) === true; + } catch (e) { + // This occurs when attempting to evaluate a reserved word using eval (`eval('typeof let')`). + // If the variable is a reserved word, it's definitely not in scope, so return false. + if (e && e instanceof SyntaxError) { + return false; + } + + // If it's another kind of error, don't swallow it. + throw e; + } +} diff --git a/packages/@ember/template-compiler/lib/dasherize-component-name.ts b/packages/@ember/template-compiler/lib/dasherize-component-name.ts new file mode 100644 index 00000000000..52bdda1329b --- /dev/null +++ b/packages/@ember/template-compiler/lib/dasherize-component-name.ts @@ -0,0 +1,22 @@ +import { Cache } from '@ember/-internals/utils'; + +/* + This diverges from `Ember.String.dasherize` so that`` can resolve to `x-foo`. + `Ember.String.dasherize` would resolve it to `xfoo`.. +*/ +const SIMPLE_DASHERIZE_REGEXP = /[A-Z]|::/g; +const ALPHA = /[A-Za-z0-9]/; + +export default new Cache(1000, (key) => + key.replace(SIMPLE_DASHERIZE_REGEXP, (char, index) => { + if (char === '::') { + return '/'; + } + + if (index === 0 || !ALPHA.test(key[index - 1]!)) { + return char.toLowerCase(); + } + + return `-${char.toLowerCase()}`; + }) +); diff --git a/packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts b/packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts new file mode 100644 index 00000000000..f2984a8c3ae --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts @@ -0,0 +1,119 @@ +import { assert, deprecate } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; + +/** + @module ember +*/ + +/** + A Glimmer2 AST transformation that asserts against + + ```handlebars + {{attrs.foo.bar}} + ``` + + ...as well as `{{#if attrs.foo}}`, `{{deeply (nested attrs.foobar.baz)}}`. + + @private + @class AssertAgainstAttrs +*/ + +export default function assertAgainstAttrs(env: EmberASTPluginEnvironment): ASTPlugin { + let { builders: b } = env.syntax; + let moduleName = env.meta?.moduleName; + + let stack: string[][] = [[]]; + + function updateBlockParamsStack(blockParams: string[]) { + let parent = stack[stack.length - 1]; + assert('has parent', parent); + stack.push(parent.concat(blockParams)); + } + + return { + name: 'assert-against-attrs', + + visitor: { + Template: { + enter(node: AST.Template) { + updateBlockParamsStack(node.blockParams); + }, + exit() { + stack.pop(); + }, + }, + + Block: { + enter(node: AST.Block) { + updateBlockParamsStack(node.blockParams); + }, + exit() { + stack.pop(); + }, + }, + + ElementNode: { + enter(node: AST.ElementNode) { + updateBlockParamsStack(node.blockParams); + }, + exit() { + stack.pop(); + }, + }, + + PathExpression(node: AST.PathExpression): AST.Node | void { + if (isAttrs(node, stack[stack.length - 1]!)) { + assert( + `Using {{attrs}} to reference named arguments is not supported. {{${ + node.original + }}} should be updated to {{@${node.original.slice(6)}}}. ${calculateLocationDisplay( + moduleName, + node.loc + )}` + ); + } else if (isThisDotAttrs(node)) { + // When removing this, ensure `{{this.attrs.foo}}` is left as-is, without triggering + // any assertions/deprecations. It's perfectly legal to reference `{{this.attrs.foo}}` + // in the template since it is a real property on the backing class – it will give you + // a `MutableCell` wrapper object, but maybe that's what you want. And in any case, + // there is no compelling to special case that property access. + deprecate( + `Using {{this.attrs}} to reference named arguments has been deprecated. {{${ + node.original + }}} should be updated to {{@${node.original.slice(11)}}}. ${calculateLocationDisplay( + moduleName, + node.loc + )}`, + false, + { + id: 'attrs-arg-access', + url: 'https://deprecations.emberjs.com/v3.x/#toc_attrs-arg-access', + until: '6.0.0', + for: 'ember-source', + since: { + available: '3.26.0', + enabled: '3.26.0', + }, + } + ); + + return b.path(`@${node.original.slice(11)}`, node.loc); + } + }, + }, + }; +} + +function isAttrs(node: AST.PathExpression, symbols: string[]) { + return ( + node.head.type === 'VarHead' && + node.head.name === 'attrs' && + symbols.indexOf(node.head.name) === -1 + ); +} + +function isThisDotAttrs(node: AST.PathExpression) { + return node.head.type === 'ThisHead' && node.tail[0] === 'attrs'; +} diff --git a/packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts b/packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts new file mode 100644 index 00000000000..c680f3b7259 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts @@ -0,0 +1,37 @@ +import { assert } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; + +/** + @module ember +*/ + +/** + Prevents usage of named outlets, a legacy concept in Ember removed in 4.0. + + @private + @class AssertAgainstNamedOutlets +*/ +export default function assertAgainstNamedOutlets(env: EmberASTPluginEnvironment): ASTPlugin { + let moduleName = env.meta?.moduleName; + + return { + name: 'assert-against-named-outlets', + + visitor: { + MustacheStatement(node: AST.MustacheStatement) { + if ( + node.path.type === 'PathExpression' && + node.path.original === 'outlet' && + node.params[0] + ) { + let sourceInformation = calculateLocationDisplay(moduleName, node.loc); + assert( + `Named outlets were removed in Ember 4.0. See https://deprecations.emberjs.com/v3.x#toc_route-render-template for guidance on alternative APIs for named outlet use cases. ${sourceInformation}` + ); + } + }, + }, + }; +} diff --git a/packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts b/packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts new file mode 100644 index 00000000000..f16e76056c4 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts @@ -0,0 +1,27 @@ +import { assert } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; +import { isPath } from './utils'; + +export default function errorOnInputWithContent(env: EmberASTPluginEnvironment): ASTPlugin { + let moduleName = env.meta?.moduleName; + + return { + name: 'assert-input-helper-without-block', + + visitor: { + BlockStatement(node: AST.BlockStatement) { + if (isPath(node.path) && node.path.original === 'input') { + assert(assertMessage(moduleName, node)); + } + }, + }, + }; +} + +function assertMessage(moduleName: string | undefined, node: AST.BlockStatement): string { + let sourceInformation = calculateLocationDisplay(moduleName, node.loc); + + return `The {{input}} helper cannot be used in block form. ${sourceInformation}`; +} diff --git a/packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts b/packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts new file mode 100644 index 00000000000..b769ba64b1b --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts @@ -0,0 +1,47 @@ +import { assert } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; + +export default function assertReservedNamedArguments(env: EmberASTPluginEnvironment): ASTPlugin { + let moduleName = env.meta?.moduleName; + + return { + name: 'assert-reserved-named-arguments', + + visitor: { + // In general, we don't assert on the invocation side to avoid creating migration + // hazards (e.g. using angle bracket to invoke a classic component that uses + // `this.someReservedName`. However, we want to avoid leaking special internal + // things, such as `__ARGS__`, so those would need to be asserted on both sides. + + AttrNode({ name, loc }: AST.AttrNode) { + if (name === '@__ARGS__') { + assert(`${assertMessage(name)} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + + HashPair({ key, loc }: AST.HashPair) { + if (key === '__ARGS__') { + assert(`${assertMessage(key)} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + + PathExpression({ original, loc }: AST.PathExpression) { + if (isReserved(original)) { + assert(`${assertMessage(original)} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + }, + }; +} + +const RESERVED = ['@arguments', '@args', '@block', '@else']; + +function isReserved(name: string): boolean { + return RESERVED.indexOf(name) !== -1 || Boolean(name.match(/^@[^a-z]/)); +} + +function assertMessage(name: string): string { + return `'${name}' is reserved.`; +} diff --git a/packages/@ember/template-compiler/lib/plugins/index.ts b/packages/@ember/template-compiler/lib/plugins/index.ts new file mode 100644 index 00000000000..6666ba891e7 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/index.ts @@ -0,0 +1,54 @@ +import AssertAgainstAttrs from './assert-against-attrs'; +import AssertAgainstNamedOutlets from './assert-against-named-outlets'; +import AssertInputHelperWithoutBlock from './assert-input-helper-without-block'; +import AssertReservedNamedArguments from './assert-reserved-named-arguments'; +import TransformActionSyntax from './transform-action-syntax'; +import TransformEachInIntoEach from './transform-each-in-into-each'; +import TransformEachTrackArray from './transform-each-track-array'; +import TransformInElement from './transform-in-element'; +import TransformQuotedBindingsIntoJustBindings from './transform-quoted-bindings-into-just-bindings'; +import TransformResolutions from './transform-resolutions'; +import TransformWrapMountAndOutlet from './transform-wrap-mount-and-outlet'; + +// order of plugins is important +export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([ + TransformQuotedBindingsIntoJustBindings, + AssertReservedNamedArguments, + TransformActionSyntax, + AssertAgainstAttrs, + TransformEachInIntoEach, + AssertInputHelperWithoutBlock, + TransformInElement, + TransformEachTrackArray, + AssertAgainstNamedOutlets, + TransformWrapMountAndOutlet, + TransformResolutions, +]); + +export const STRICT_MODE_TRANSFORMS = Object.freeze([ + TransformQuotedBindingsIntoJustBindings, + AssertReservedNamedArguments, + TransformActionSyntax, + TransformEachInIntoEach, + TransformInElement, + TransformEachTrackArray, + AssertAgainstNamedOutlets, + TransformWrapMountAndOutlet, +]); + +export const STRICT_MODE_KEYWORDS = Object.freeze([ + 'action', + 'mut', + 'readonly', + 'unbound', + + // TransformEachInIntoEach + '-each-in', + // TransformInElement + '-in-el-null', + // TransformEachTrackArray + '-track-array', + // TransformWrapMountAndOutlet + '-mount', + '-outlet', +]); diff --git a/packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts b/packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts new file mode 100644 index 00000000000..2ee6fdd4bc4 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts @@ -0,0 +1,67 @@ +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import type { Builders, EmberASTPluginEnvironment } from '../types'; +import { isPath } from './utils'; + +/** + @module ember +*/ + +/** + A Glimmer2 AST transformation that replaces all instances of + + ```handlebars +