diff --git a/packages/@ember/debug/ember-inspector-support/container-debug.ts b/packages/@ember/debug/ember-inspector-support/container-debug.ts new file mode 100644 index 00000000000..d38e0d53a62 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/container-debug.ts @@ -0,0 +1,105 @@ +import DebugPort from './debug-port'; + +export default class ContainerDebug extends DebugPort { + declare objectToConsole: any; + get objectInspector() { + return this.namespace?.objectInspector; + } + + get container() { + return this.namespace?.owner?.__container__; + } + + TYPES_TO_SKIP = [ + 'component-lookup', + 'container-debug-adapter', + 'resolver-for-debugging', + 'event_dispatcher', + ]; + + static { + this.prototype.portNamespace = 'container'; + this.prototype.messages = { + getTypes(this: ContainerDebug) { + this.sendMessage('types', { + types: this.getTypes(), + }); + }, + getInstances(this: ContainerDebug, message: any) { + let instances = this.getInstances(message.containerType); + if (instances) { + this.sendMessage('instances', { + instances, + status: 200, + }); + } else { + this.sendMessage('instances', { + status: 404, + }); + } + }, + sendInstanceToConsole(this: ContainerDebug, message: any) { + const instance = this.container.lookup(message.name); + this.objectToConsole.sendValueToConsole(instance); + }, + }; + } + + typeFromKey(key: string) { + return key.split(':').shift()!; + } + + nameFromKey(key: string) { + return key.split(':').pop(); + } + + shouldHide(type: string) { + return type[0] === '-' || this.TYPES_TO_SKIP.indexOf(type) !== -1; + } + + instancesByType() { + let key; + let instancesByType: Record = {}; + let cache = this.container.cache; + // Detect if InheritingDict (from Ember < 1.8) + if (typeof cache.dict !== 'undefined' && typeof cache.eachLocal !== 'undefined') { + cache = cache.dict; + } + for (key in cache) { + const type = this.typeFromKey(key); + if (this.shouldHide(type)) { + continue; + } + if (instancesByType[type] === undefined) { + instancesByType[type] = []; + } + instancesByType[type].push({ + fullName: key, + instance: cache[key], + }); + } + return instancesByType; + } + + getTypes() { + let key; + let types = []; + const instancesByType = this.instancesByType(); + for (key in instancesByType) { + types.push({ name: key, count: instancesByType[key].length }); + } + return types; + } + + getInstances(type: any) { + const instances = this.instancesByType()[type]; + if (!instances) { + return null; + } + return instances.map((item: any) => ({ + name: this.nameFromKey(item.fullName), + fullName: item.fullName, + inspectable: this.objectInspector.canSend(item.instance), + })); + } +} diff --git a/packages/@ember/debug/ember-inspector-support/data-debug.ts b/packages/@ember/debug/ember-inspector-support/data-debug.ts new file mode 100644 index 00000000000..04fcc0f4139 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/data-debug.ts @@ -0,0 +1,191 @@ +import DebugPort from './debug-port'; +import { guidFor } from '@ember/debug/ember-inspector-support/utils/ember/object/internals'; + +export default class DataDebug extends DebugPort { + declare portNamespace: string; + declare sentTypes: Record; + declare sentRecords: Record; + init() { + super.init(); + this.sentTypes = {}; + this.sentRecords = {}; + } + + releaseTypesMethod: Function | null = null; + releaseRecordsMethod: Function | null = null; + + get adapter() { + const owner = this.namespace?.owner; + + // dataAdapter:main is deprecated + let adapter = this._resolve('data-adapter:main') && owner.lookup('data-adapter:main'); + // column limit is now supported at the inspector level + if (adapter) { + adapter.attributeLimit = 100; + return adapter; + } + + return null; + } + + _resolve(name: string) { + const owner = this.namespace?.owner; + + return owner.resolveRegistration(name); + } + + get objectInspector() { + return this.namespace?.objectInspector; + } + + modelTypesAdded(types: any[]) { + let typesToSend; + typesToSend = types.map((type) => this.wrapType(type)); + this.sendMessage('modelTypesAdded', { + modelTypes: typesToSend, + }); + } + + modelTypesUpdated(types: any[]) { + let typesToSend = types.map((type) => this.wrapType(type)); + this.sendMessage('modelTypesUpdated', { + modelTypes: typesToSend, + }); + } + + wrapType(type: any) { + const objectId = guidFor(type.object); + this.sentTypes[objectId] = type; + + return { + columns: type.columns, + count: type.count, + name: type.name, + objectId, + }; + } + + recordsAdded(recordsReceived: any[]) { + let records = recordsReceived.map((record) => this.wrapRecord(record)); + this.sendMessage('recordsAdded', { records }); + } + + recordsUpdated(recordsReceived: any[]) { + let records = recordsReceived.map((record) => this.wrapRecord(record)); + this.sendMessage('recordsUpdated', { records }); + } + + recordsRemoved(index: number, count: number) { + this.sendMessage('recordsRemoved', { index, count }); + } + + wrapRecord(record: any) { + const objectId = guidFor(record.object); + let columnValues: Record = {}; + let searchKeywords: any[] = []; + this.sentRecords[objectId] = record; + // make objects clonable + for (let i in record.columnValues) { + columnValues[i] = this.objectInspector.inspect(record.columnValues[i]); + } + // make sure keywords can be searched and clonable + searchKeywords = record.searchKeywords.filter( + (keyword: any) => typeof keyword === 'string' || typeof keyword === 'number' + ); + return { + columnValues, + searchKeywords, + filterValues: record.filterValues, + color: record.color, + objectId, + }; + } + + releaseTypes() { + if (this.releaseTypesMethod) { + this.releaseTypesMethod(); + this.releaseTypesMethod = null; + this.sentTypes = {}; + } + } + + releaseRecords() { + if (this.releaseRecordsMethod) { + this.releaseRecordsMethod(); + this.releaseRecordsMethod = null; + this.sentRecords = {}; + } + } + + willDestroy() { + super.willDestroy(); + this.releaseRecords(); + this.releaseTypes(); + } + + static { + this.prototype.portNamespace = 'data'; + this.prototype.messages = { + checkAdapter(this: DataDebug) { + this.sendMessage('hasAdapter', { hasAdapter: Boolean(this.adapter) }); + }, + + getModelTypes(this: DataDebug) { + this.modelTypesAdded([]); + this.releaseTypes(); + this.releaseTypesMethod = this.adapter.watchModelTypes( + (types: any) => { + this.modelTypesAdded(types); + }, + (types: any) => { + this.modelTypesUpdated(types); + } + ); + }, + + releaseModelTypes(this: DataDebug) { + this.releaseTypes(); + }, + + getRecords(this: DataDebug, message: any) { + const type = this.sentTypes[message.objectId]; + this.releaseRecords(); + + let typeOrName; + if (this.adapter.acceptsModelName) { + // Ember >= 1.3 + typeOrName = type.name; + } + + this.recordsAdded([]); + let releaseMethod = this.adapter.watchRecords( + typeOrName, + (recordsReceived: any) => { + this.recordsAdded(recordsReceived); + }, + (recordsUpdated: any) => { + this.recordsUpdated(recordsUpdated); + }, + (index: number, count: number) => { + this.recordsRemoved(index, count); + } + ); + this.releaseRecordsMethod = releaseMethod; + }, + + releaseRecords(this: DataDebug) { + this.releaseRecords(); + }, + + inspectModel(this: DataDebug, message: any) { + this.objectInspector.sendObject(this.sentRecords[message.objectId].object); + }, + + getFilters(this: DataDebug) { + this.sendMessage('filters', { + filters: this.adapter.getFilters(), + }); + }, + }; + } +} diff --git a/packages/@ember/debug/ember-inspector-support/debug-port.ts b/packages/@ember/debug/ember-inspector-support/debug-port.ts new file mode 100644 index 00000000000..016215f88d5 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/debug-port.ts @@ -0,0 +1,48 @@ +import BaseObject from '@ember/debug/ember-inspector-support/utils/base-object'; + +export default class DebugPort extends BaseObject { + declare port: any; + declare portNamespace: string; + declare messages: Record; + constructor(data: any) { + super(data); + if (!data) { + throw new Error('need to pass data'); + } + this.port = this.namespace?.port; + this.setupOrRemovePortListeners('on'); + } + + willDestroy() { + super.willDestroy(); + this.setupOrRemovePortListeners('off'); + } + + sendMessage(name: string, message?: any) { + if (this.isDestroyed) return; + this.port.send(this.messageName(name), message); + } + + messageName(name: string) { + let messageName = name; + if (this.portNamespace) { + messageName = `${this.portNamespace}:${messageName}`; + } + return messageName; + } + + /** + * Setup or tear down port listeners. Call on `init` and `willDestroy` + * @param {String} onOrOff 'on' or 'off' the functions to call i.e. port.on or port.off for adding or removing listeners + */ + setupOrRemovePortListeners(onOrOff: 'on' | 'off') { + let port = this.port; + let messages = this.messages; + + for (let name in messages) { + if (Object.prototype.hasOwnProperty.call(messages, name)) { + port[onOrOff](this.messageName(name), this, messages[name]); + } + } + } +} diff --git a/packages/@ember/debug/ember-inspector-support/deprecation-debug.ts b/packages/@ember/debug/ember-inspector-support/deprecation-debug.ts new file mode 100644 index 00000000000..01812ddd7b7 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/deprecation-debug.ts @@ -0,0 +1,270 @@ +import DebugPort from './debug-port'; +import SourceMap from '@ember/debug/ember-inspector-support/libs/source-map'; + +import { registerDeprecationHandler } from '@ember/debug'; +import { guidFor } from '@ember/debug/ember-inspector-support/utils/ember/object/internals'; +import { cancel, debounce } from '@ember/runloop'; +import type SourceMapSupport from '@ember/debug/ember-inspector-support/libs/source-map'; + +export default class DeprecationDebug extends DebugPort { + declare options: any; + private declare _warned: boolean; + declare debounce: any; + private declare _watching: any; + declare deprecationsToSend: { + stackStr: string; + message: string; + url: string; + count: number; + id: string; + sources: any[]; + }[]; + private declare sourceMap: SourceMapSupport; + declare groupedDeprecations: any; + declare deprecations: any; + private declare __emberCliConfig: any; + static { + this.prototype.portNamespace = 'deprecation'; + this.prototype.sourceMap = new SourceMap(); + this.prototype.messages = { + watch(this: DeprecationDebug) { + this._watching = true; + let grouped = this.groupedDeprecations; + let deprecations = []; + for (let i in grouped) { + if (!Object.prototype.hasOwnProperty.call(grouped, i)) { + continue; + } + deprecations.push(grouped[i]); + } + this.sendMessage('deprecationsAdded', { + deprecations, + }); + this.sendPending(); + }, + + sendStackTraces( + this: DeprecationDebug, + message: { deprecation: { message: string; sources: { stackStr: string }[] } } + ) { + let deprecation = message.deprecation; + deprecation.sources.forEach((source) => { + let stack = source.stackStr; + let stackArray = stack.split('\n'); + stackArray.unshift(`Ember Inspector (Deprecation Trace): ${deprecation.message || ''}`); + this.adapter.log(stackArray.join('\n')); + }); + }, + + getCount(this: DeprecationDebug) { + this.sendCount(); + }, + + clear(this: DeprecationDebug) { + cancel(this.debounce); + this.deprecations.length = 0; + this.groupedDeprecations = {}; + this.sendCount(); + }, + + release(this: DeprecationDebug) { + this._watching = false; + }, + + setOptions(this: DeprecationDebug, { options }: any) { + this.options.toggleDeprecationWorkflow = options.toggleDeprecationWorkflow; + }, + }; + } + + get adapter() { + return this.port?.adapter; + } + + get emberCliConfig() { + return this.__emberCliConfig || this.namespace?.generalDebug.emberCliConfig; + } + + set emberCliConfig(value) { + this.__emberCliConfig = value; + } + + constructor(data: any) { + super(data); + + this.deprecations = []; + this.deprecationsToSend = []; + this.groupedDeprecations = {}; + this.options = { + toggleDeprecationWorkflow: false, + }; + + this.handleDeprecations(); + } + + /** + * Checks if ember-cli and looks for source maps. + */ + fetchSourceMap(stackStr: string) { + if (this.emberCliConfig && this.emberCliConfig.environment === 'development') { + return this.sourceMap.map(stackStr).then((mapped: any[]) => { + if (mapped && mapped.length > 0) { + let source = mapped.find( + (item: any) => + item.source && + Boolean(item.source.match(new RegExp(this.emberCliConfig.modulePrefix))) + ); + + if (source) { + source.found = true; + } else { + source = mapped[0]; + source.found = false; + } + return source; + } + }, null); + } else { + return Promise.resolve(null); + } + } + + sendPending() { + if (this.isDestroyed) { + return; + } + + let deprecations: { stackStr: string }[] = []; + + let promises = Promise.all( + this.deprecationsToSend.map((deprecation) => { + let obj: any; + let promise = Promise.resolve(undefined); + let grouped = this.groupedDeprecations; + this.deprecations.push(deprecation); + const id = guidFor(deprecation.message); + obj = grouped[id]; + if (obj) { + obj.count++; + obj.url = obj.url || deprecation.url; + } else { + obj = deprecation; + obj.count = 1; + obj.id = id; + obj.sources = []; + grouped[id] = obj; + } + let found = obj.sources.find((s: any) => s.stackStr === deprecation.stackStr); + if (!found) { + let stackStr = deprecation.stackStr; + promise = this.fetchSourceMap(stackStr).then((map) => { + obj.sources.push({ map, stackStr }); + if (map) { + obj.hasSourceMap = true; + } + return undefined; + }, null); + } + return promise.then(() => { + delete obj.stackStr; + if (!deprecations.includes(obj)) { + deprecations.push(obj); + } + }, null); + }) + ); + + promises.then(() => { + this.sendMessage('deprecationsAdded', { deprecations }); + this.deprecationsToSend.length = 0; + this.sendCount(); + }, null); + } + + sendCount() { + if (this.isDestroyed) { + return; + } + + this.sendMessage('count', { + count: this.deprecations.length + this.deprecationsToSend.length, + }); + } + + willDestroy() { + cancel(this.debounce); + return super.willDestroy(); + } + + handleDeprecations() { + registerDeprecationHandler((message, options, next) => { + if (!this.adapter) { + next(message, options); + return; + } + + /* global __fail__*/ + + let error: any; + + try { + // @ts-expect-error When using new Error, we can't do the arguments check for Chrome. Alternatives are welcome + __fail__.fail(); + } catch (e) { + error = e; + } + + let stack; + let stackStr = ''; + if (error.stack) { + // var stack; + if (error['arguments']) { + // Chrome + stack = error.stack + .replace(/^\s+at\s+/gm, '') + .replace(/^([^(]+?)([\n$])/gm, '{anonymous}($1)$2') + .replace(/^Object.\s*\(([^)]+)\)/gm, '{anonymous}($1)') + .split('\n'); + stack.shift(); + } else { + // Firefox + stack = error.stack + .replace(/(?:\n@:0)?\s+$/m, '') + .replace(/^\(/gm, '{anonymous}(') + .split('\n'); + } + + stackStr = `\n ${stack.slice(2).join('\n ')}`; + } + + let url; + if (options && typeof options === 'object') { + url = options.url; + } + + const deprecation = { message, stackStr, url } as any; + + // For ember-debug testing we usually don't want + // to catch deprecations + if (!this.namespace?.IGNORE_DEPRECATIONS) { + this.deprecationsToSend.push(deprecation); + cancel(this.debounce); + if (this._watching) { + this.debounce = debounce(this, this.sendPending, 100); + } else { + this.debounce = debounce(this, this.sendCount, 100); + } + if (!this._warned) { + this.adapter.warn( + 'Deprecations were detected, see the Ember Inspector deprecations tab for more details.' + ); + this._warned = true; + } + } + + if (this.options.toggleDeprecationWorkflow) { + next(message, options); + } + }); + } +} diff --git a/packages/@ember/debug/ember-inspector-support/general-debug.ts b/packages/@ember/debug/ember-inspector-support/general-debug.ts new file mode 100644 index 00000000000..bbe8d2d93fc --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/general-debug.ts @@ -0,0 +1,137 @@ +/* eslint no-empty:0 */ +import libraries from '@ember/-internals/metal/lib/libraries'; +import DebugPort from './debug-port'; + +/** + * Class that handles gathering general information of the inspected app. + * ex: + * - Determines if the app was booted + * - Gathers the libraries. Found in the info tab of the inspector. + * - Gathers ember-cli configuration information from the meta tags. + * + * @module ember-debug/general-debug + */ +export default class GeneralDebug extends DebugPort { + declare portNamespace: string; + declare messages: { + /** + * Called from the inspector to check if the inspected app has been booted. + */ + applicationBooted(): void; + /** + * Called from the inspector to fetch the libraries that are displayed in + * the info tab. + */ + getLibraries(): void; + getEmberCliConfig(): void; + /** + * Called from the inspector to refresh the inspected app. + * Used in case the inspector was opened late and therefore missed capturing + * all info. + */ + refresh(): void; + }; + /** + * Fetches the ember-cli configuration info and sets them on + * the `emberCliConfig` property. + */ + getAppConfig() { + let found = findMetaTag('name', /environment$/); + if (found) { + try { + return JSON.parse(decodeURI(found.getAttribute('content')!)); + } catch {} + } + } + + /** + * Passed on creation. + * + * @type {EmberDebug} + */ + + /** + * Set on creation. + * Contains ember-cli configuration info. + * + * Info used to determine the file paths of an ember-cli app. + */ + emberCliConfig = this.getAppConfig(); + + /** + * Sends a reply back indicating if the app has been booted. + * + * `__inspector__booted` is a property set on the application instance + * when the ember-debug is inserted into the target app. + * see: startup-wrapper. + */ + sendBooted() { + this.sendMessage('applicationBooted', { + booted: this.namespace.owner.__inspector__booted, + }); + } + + /** + * Sends a reply back indicating that ember-debug has been reset. + * We need to reset ember-debug to remove state between tests. + */ + sendReset() { + this.sendMessage('reset'); + } + + static { + /** + * Used by the DebugPort + * + * @type {String} + */ + this.prototype.portNamespace = 'general'; + this.prototype.messages = { + /** + * Called from the inspector to check if the inspected app has been booted. + */ + applicationBooted(this: GeneralDebug) { + this.sendBooted(); + }, + + /** + * Called from the inspector to fetch the libraries that are displayed in + * the info tab. + */ + getLibraries(this: GeneralDebug) { + this.sendMessage('libraries', { + libraries: libraries?._registry, + }); + }, + + getEmberCliConfig(this: GeneralDebug) { + this.sendMessage('emberCliConfig', { + emberCliConfig: this.emberCliConfig, + }); + }, + + /** + * Called from the inspector to refresh the inspected app. + * Used in case the inspector was opened late and therefore missed capturing + * all info. + */ + refresh() { + window.location.reload(); + }, + }; + } +} + +/** + * Finds a meta tag by searching through a certain meta attribute. + */ +function findMetaTag(attribute: string, regExp = /.*/) { + let metas = document.querySelectorAll(`meta[${attribute}]`); + for (let i = 0; i < metas.length; i++) { + let match = metas[i]!.getAttribute(attribute)?.match(regExp); + if (match) { + return metas[i]; + } + } + return null; +} diff --git a/packages/@ember/debug/ember-inspector-support/index.ts b/packages/@ember/debug/ember-inspector-support/index.ts new file mode 100644 index 00000000000..6b98b7d007c --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/index.ts @@ -0,0 +1,314 @@ +import { VERSION } from '@ember/version'; +import type Adapters from './adapters'; +import { guidFor } from '@ember/object/internals'; +import { A } from '@ember/array'; +import Namespace from '@ember/application/namespace'; +import Application from '@ember/application'; +import type ApplicationInstance from '@ember/application/instance'; + +export function setupEmberInspectorSupport() { + if ((window as any).EMBER_INSPECTOR_SUPPORT_BUNDLED) { + return; + } + (window as any).EMBER_INSPECTOR_SUPPORT_BUNDLED = true; + window.addEventListener('ember-inspector-loaded' as any, (event: CustomEvent) => { + const adapter = event.detail.adapter; + const EMBER_VERSIONS_SUPPORTED = event.detail.EMBER_VERSIONS_SUPPORTED; + void loadEmberDebug(adapter, EMBER_VERSIONS_SUPPORTED); + }); + + const e = new Event('ember-inspector-support-setup'); + window.dispatchEvent(e); +} + +// eslint-disable-next-line disable-features/disable-async-await +async function loadEmberDebug( + adapter: keyof typeof Adapters, + EMBER_VERSIONS_SUPPORTED: [string, string] +) { + const w = window as any; + // global to prevent injection + if (w.NO_EMBER_DEBUG) { + return; + } + + // @ts-ignore + const Adapters = await import('./adapters'); + // @ts-ignore + const MainModule = await import('./main'); + + if (!versionTest(VERSION, EMBER_VERSIONS_SUPPORTED)) { + // Wrong inspector version. Redirect to the correct version. + sendVersionMiss(); + return; + } + + // prevent from injecting twice + if (!w.EmberInspector) { + w.EmberInspector = new MainModule(); + w.EmberInspector.Adapter = Adapters[adapter]; + + onApplicationStart(function appStarted(instance: ApplicationInstance) { + let app = instance.application; + if (!('__inspector__booted' in app)) { + // Watch for app reset/destroy + app.reopen({ + reset: function (this: Application) { + (this as any).__inspector__booted = false; + this._super.apply(this, arguments as any); + }, + }); + } + + if (instance && !('__inspector__booted' in instance)) { + instance.reopen({ + // Clean up on instance destruction + willDestroy() { + if (w.EmberInspector.owner === instance) { + w.EmberInspector.destroyContainer(); + w.EmberInspector.clear(); + } + return (this as any)._super.apply(this, arguments); + }, + }); + + if (!w.EmberInspector._application) { + setTimeout(() => bootEmberInspector(instance), 0); + } + } + }); + } + + function bootEmberInspector(appInstance: ApplicationInstance) { + (appInstance.application as any).__inspector__booted = true; + (appInstance as any).__inspector__booted = true; + + // Boot the inspector (or re-boot if already booted, for example in tests) + w.EmberInspector._application = appInstance.application; + w.EmberInspector.owner = appInstance; + w.EmberInspector.start(true); + } + + // There's probably a better way + // to determine when the application starts + // but this definitely works + function onApplicationStart(callback: Function) { + const adapterInstance = new Adapters[adapter](); + + adapterInstance.onMessageReceived(function (message) { + if (message.type === 'app-picker-loaded') { + sendApps(adapterInstance, getApplications()); + } + + if (message.type === 'app-selected') { + let current = w.EmberInspector._application; + let selected = getApplications().find((app: any) => guidFor(app) === message.applicationId); + + if (selected && current !== selected && selected.__deprecatedInstance__) { + bootEmberInspector(selected.__deprecatedInstance__); + } + } + }); + + let apps = getApplications(); + + sendApps(adapterInstance, apps); + + function loadInstance(app: Application) { + const applicationInstances = app._applicationInstances && [...app._applicationInstances]; + let instance = app.__deprecatedInstance__ || applicationInstances[0]; + if (instance) { + // App started + setupInstanceInitializer(app, callback); + callback(instance); + return true; + } + return; + } + + let app: Application; + for (let i = 0, l = apps.length; i < l; i++) { + app = apps[i]; + // We check for the existance of an application instance because + // in Ember > 3 tests don't destroy the app when they're done but the app has no booted instances. + if (app._readinessDeferrals === 0) { + if (loadInstance(app)) { + break; + } + } + + // app already run initializers, but no instance, use _bootPromise and didBecomeReady + if (app._bootPromise) { + app._bootPromise.then((app) => { + loadInstance(app); + }); + } + + app.reopen({ + didBecomeReady(this: Application) { + this._super.apply(this, arguments as any); + setTimeout(() => loadInstance(app), 0); + }, + }); + } + Application.initializer({ + name: 'ember-inspector-booted', + initialize: function (app) { + setupInstanceInitializer(app, callback); + }, + }); + } + + function setupInstanceInitializer(app: Application, callback: Function) { + if (!(app as any).__inspector__setup) { + (app as any).__inspector__setup = true; + + // We include the app's guid in the initializer name because in Ember versions < 3 + // registering an instance initializer with the same name, even if on a different app, + // triggers an error because instance initializers seem to be global instead of per app. + app.instanceInitializer({ + name: 'ember-inspector-app-instance-booted-' + guidFor(app), + initialize: function (instance) { + callback(instance); + }, + }); + } + } + + /** + * Get all the Ember.Application instances from Ember.Namespace.NAMESPACES + * and add our own applicationId and applicationName to them + */ + function getApplications() { + let namespaces = A(Namespace.NAMESPACES); + + let apps = namespaces.filter(function (namespace) { + return namespace instanceof Application; + }); + + return apps.map(function (app: any) { + // Add applicationId and applicationName to the app + let applicationId = guidFor(app); + let applicationName = app.name || app.modulePrefix || `(unknown app - ${applicationId})`; + + Object.assign(app, { + applicationId, + applicationName, + }); + + return app; + }); + } + + let channel = new MessageChannel(); + let port = channel.port1; + window.postMessage('debugger-client', '*', [channel.port2]); + + let registeredMiss = false; + + /** + * This function is called if the app's Ember version + * is not supported by this version of the inspector. + * + * It sends a message to the inspector app to redirect + * to an inspector version that supports this Ember version. + */ + function sendVersionMiss() { + if (registeredMiss) { + return; + } + + registeredMiss = true; + + port.addEventListener('message', (message) => { + if (message.type === 'check-version') { + sendVersionMismatch(); + } + }); + + sendVersionMismatch(); + + port.start(); + + function sendVersionMismatch() { + port.postMessage({ + name: 'version-mismatch', + version: VERSION, + from: 'inspectedWindow', + }); + } + } + + function sendApps(adapter: any, apps: any[]) { + const serializedApps = apps.map((app) => { + return { + applicationName: app.applicationName, + applicationId: app.applicationId, + }; + }); + + adapter.sendMessage({ + type: 'apps-loaded', + apps: serializedApps, + from: 'inspectedWindow', + }); + } + + /** + * Checks if a version is between two different versions. + * version should be >= left side, < right side + */ + function versionTest(version: string, between: [string, string]) { + let fromVersion = between[0]; + let toVersion = between[1]; + + if (compareVersion(version, fromVersion) === -1) { + return false; + } + return !toVersion || compareVersion(version, toVersion) === -1; + } + + /** + * Compares two Ember versions. + * + * Returns: + * `-1` if version1 < version + * 0 if version1 == version2 + * 1 if version1 > version2 + */ + function compareVersion(version1: string, version2: string) { + let compared, i; + let version1Split = cleanupVersion(version1).split('.'); + let version2Split = cleanupVersion(version2).split('.'); + for (i = 0; i < 3; i++) { + compared = compare(Number(version1Split[i]), Number(version2Split[i])); + if (compared !== 0) { + return compared; + } + } + return 0; + } + + /** + * Remove -alpha, -beta, etc from versions + */ + function cleanupVersion(version: string) { + return version.replace(/-.*/g, ''); + } + + /** + * 0: same + * -1: < + * 1: > + */ + function compare(val: number, number: number) { + if (val === number) { + return 0; + } else if (val < number) { + return -1; + } else if (val > number) { + return 1; + } + return; + } +} diff --git a/packages/@ember/debug/ember-inspector-support/libs/capture-render-tree.ts b/packages/@ember/debug/ember-inspector-support/libs/capture-render-tree.ts new file mode 100644 index 00000000000..65e9656c02a --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/libs/capture-render-tree.ts @@ -0,0 +1,15 @@ +import captureRenderTree from '@ember/debug/lib/capture-render-tree'; +import { ENV } from '@ember/-internals/environment'; + +let capture = captureRenderTree; +// Ember 3.14+ comes with debug render tree, but the version in 3.14.0/3.14.1 is buggy +if (captureRenderTree) { + if (ENV._DEBUG_RENDER_TREE) { + capture = captureRenderTree; + } else { + capture = function captureRenderTree() { + return []; + }; + } +} +export default capture; diff --git a/packages/@ember/debug/ember-inspector-support/libs/promise-assembler.ts b/packages/@ember/debug/ember-inspector-support/libs/promise-assembler.ts new file mode 100644 index 00000000000..3ef27ddf3e1 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/libs/promise-assembler.ts @@ -0,0 +1,190 @@ +/** + Original implementation and the idea behind the `PromiseAssembler`, + `Promise` model, and other work related to promise inspection was done + by Stefan Penner (@stefanpenner) thanks to McGraw Hill Education (@mhelabs) + and Yapp Labs (@yapplabs). + */ + +import PromiseModel from '@ember/debug/ember-inspector-support/models/promise'; +import RSVP from 'rsvp'; +import BaseObject from '@ember/debug/ember-inspector-support/utils/base-object'; +import Evented from '../utils/evented'; + +export type PromiseUpdatedEvent = { + promise: PromiseModel; +}; + +export type PromiseChainedEvent = { + promise: PromiseModel; + child: PromiseModel; +}; + +class PromiseAssembler extends Evented.extend(BaseObject) { + // RSVP lib to debug + isStarted = false; + declare RSVP: any; + declare all: any[]; + declare promiseIndex: Record; + promiseChained: ((e: any) => void) | null = null; + promiseRejected: ((e: any) => void) | null = null; + promiseFulfilled: ((e: any) => void) | null = null; + promiseCreated: ((e: any) => void) | null = null; + + static { + this.prototype.RSVP = RSVP; + } + + constructor(data?: any) { + super(data); + Evented.applyTo(this); + } + + init() { + super.init(); + this.all = []; + this.promiseIndex = {}; + } + + start() { + this.RSVP.configure('instrument', true); + + this.promiseChained = (e: any) => { + chain.call(this, e); + }; + this.promiseRejected = (e: any) => { + reject.call(this, e); + }; + this.promiseFulfilled = (e: any) => { + fulfill.call(this, e); + }; + this.promiseCreated = (e: any) => { + create.bind(this)(e); + }; + + this.RSVP.on('chained', this.promiseChained); + this.RSVP.on('rejected', this.promiseRejected); + this.RSVP.on('fulfilled', this.promiseFulfilled); + this.RSVP.on('created', this.promiseCreated); + + this.isStarted = true; + } + + stop() { + if (this.isStarted) { + this.RSVP.configure('instrument', false); + this.RSVP.off('chained', this.promiseChained); + this.RSVP.off('rejected', this.promiseRejected); + this.RSVP.off('fulfilled', this.promiseFulfilled); + this.RSVP.off('created', this.promiseCreated); + + this.all.forEach((item) => { + item.destroy(); + }); + + this.all = []; + this.promiseIndex = {}; + this.promiseChained = null; + this.promiseRejected = null; + this.promiseFulfilled = null; + this.promiseCreated = null; + this.isStarted = false; + } + } + + willDestroy() { + this.stop(); + super.willDestroy(); + } + + createPromise(props: any) { + let promise = new PromiseModel(props); + let index = this.all.length; + + this.all.push(promise); + this.promiseIndex[promise.guid] = index; + return promise; + } + + find(guid?: string) { + if (guid) { + const index = this.promiseIndex[guid]; + if (index !== undefined) { + return this.all[index]; + } + } else { + return this.all; + } + } + + findOrCreate(guid: string) { + return this.find(guid) || this.createPromise({ guid }); + } + + updateOrCreate(guid: string, properties: any) { + let entry = this.find(guid); + if (entry) { + Object.assign(entry, properties); + } else { + properties = Object.assign({}, properties); + properties.guid = guid; + entry = this.createPromise(properties); + } + + return entry; + } +} + +export default PromiseAssembler; + +function fulfill(this: PromiseAssembler, event: any) { + const guid = event.guid; + const promise = this.updateOrCreate(guid, { + label: event.label, + settledAt: event.timeStamp, + state: 'fulfilled', + value: event.detail, + }); + this.trigger('fulfilled', { promise } as PromiseUpdatedEvent); +} + +function reject(this: PromiseAssembler, event: any) { + const guid = event.guid; + const promise = this.updateOrCreate(guid, { + label: event.label, + settledAt: event.timeStamp, + state: 'rejected', + reason: event.detail, + }); + this.trigger('rejected', { promise } as PromiseUpdatedEvent); +} + +function chain(this: PromiseAssembler, event: any) { + let guid = event.guid; + let promise = this.updateOrCreate(guid, { + label: event.label, + chainedAt: event.timeStamp, + }); + let children = promise.children; + let child = this.findOrCreate(event.childGuid); + + child.parent = promise; + children.push(child); + + this.trigger('chained', { promise, child } as PromiseChainedEvent); +} + +function create(this: PromiseAssembler, event: any) { + const guid = event.guid; + + const promise = this.updateOrCreate(guid, { + label: event.label, + createdAt: event.timeStamp, + stack: event.stack, + }); + + // todo fix ordering + if (!promise.state) { + promise.state = 'created'; + } + this.trigger('created', { promise } as PromiseUpdatedEvent); +} diff --git a/packages/@ember/debug/ember-inspector-support/libs/render-tree.ts b/packages/@ember/debug/ember-inspector-support/libs/render-tree.ts new file mode 100644 index 00000000000..edc5c6ba3b5 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/libs/render-tree.ts @@ -0,0 +1,711 @@ +import captureRenderTree from './capture-render-tree'; +import { guidFor } from '@ember/debug/ember-inspector-support/utils/ember/object/internals'; +import { inspect } from '@ember/debug/ember-inspector-support/utils/type-check'; +import { CustomModifierManager } from '@glimmer/manager'; +import * as GlimmerRuntime from '@glimmer/runtime'; +import type { CapturedRenderNode } from '@glimmer/interfaces/lib/runtime/debug-render-tree'; + +declare module '@glimmer/interfaces/lib/runtime/debug-render-tree' { + interface CapturedRenderNode { + meta: { + parentElement: HTMLBaseElement; + }; + } +} + +class InElementSupportProvider { + nodeMap: Map; + remoteRoots: CapturedRenderNode[]; + Wormhole: any; + debugRenderTree: any; + NewElementBuilder: any; + debugRenderTreeFunctions: { exit: any; enter: any } | undefined; + NewElementBuilderFunctions: any; + constructor(owner: any) { + this.nodeMap = new Map(); + this.remoteRoots = []; + try { + // @ts-expect-error expected error + this.Wormhole = requireModule('ember-wormhole/components/ember-wormhole'); + } catch { + // nope + } + + CustomModifierManager.prototype.getDebugInstance = (args) => args.modifier || args.delegate; + + this.debugRenderTree = + owner.lookup('renderer:-dom')?.debugRenderTree || + owner.lookup('service:-glimmer-environment')._debugRenderTree; + this.NewElementBuilder = GlimmerRuntime.NewElementBuilder; + + this.patch(); + } + + reset() { + this.nodeMap.clear(); + this.remoteRoots.length = 0; + } + + patch() { + const NewElementBuilder = GlimmerRuntime.NewElementBuilder; + const componentStack: any[] = []; + let currentElement: any = null; + + const captureNode = this.debugRenderTree.captureNode; + // this adds meta to the capture + // https://github.com/glimmerjs/glimmer-vm/pull/1575 + this.debugRenderTree.captureNode = function (id: string, state: any) { + const node = this.nodeFor(state); + const res = captureNode.call(this, id, state); + res.meta = node.meta; + return res; + }; + + const exit = this.debugRenderTree.exit; + this.debugRenderTree.exit = function (state: any) { + const node = this.nodeFor(this.stack.current); + if (node?.type === 'component' || node.type === 'keyword') { + componentStack.pop(); + } + return exit.call(this, state); + }; + + const enter = this.debugRenderTree.enter; + // this is required to have the original parentElement for in-element + // https://github.com/glimmerjs/glimmer-vm/pull/1575 + this.debugRenderTree.enter = function (...args: any) { + enter.call(this, ...args); + const node = this.nodeFor(args[0]); + if (node?.type === 'keyword' && node.name === 'in-element') { + node.meta = { + parentElement: currentElement, + }; + } + return node; + }; + + const didAppendNode = NewElementBuilder.prototype.didAppendNode; + // just some optimization for search later, not really needed + // @ts-expect-error expected error + NewElementBuilder.prototype.didAppendNode = function (...args: any) { + args[0].__emberInspectorParentNode = componentStack.slice(-1)[0]; + // @ts-expect-error expected error + return didAppendNode.call(this, ...args); + }; + + const pushElement = NewElementBuilder.prototype['pushElement']; + NewElementBuilder.prototype['pushElement'] = function (...args: any) { + // @ts-expect-error monkey patching... could be removed, just some perf gain + pushElement.call(this, ...args); + args[0].__emberInspectorParentNode = componentStack.slice(-1)[0]; + }; + + // https://github.com/glimmerjs/glimmer-vm/pull/1575 + const pushRemoteElement = NewElementBuilder.prototype.pushRemoteElement; + NewElementBuilder.prototype.pushRemoteElement = function (...args: any) { + currentElement = this.element; + // @ts-expect-error monkey patching... + return pushRemoteElement.call(this, ...args); + }; + + this.debugRenderTreeFunctions = { + enter, + exit, + }; + this.NewElementBuilderFunctions = { + pushElement, + pushRemoteElement, + didAppendNode, + }; + } + + teardown() { + if (!this.NewElementBuilderFunctions) { + return; + } + Object.assign(this.debugRenderTree, this.debugRenderTreeFunctions); + Object.assign(this.NewElementBuilder.prototype, this.NewElementBuilderFunctions); + this.NewElementBuilderFunctions = null; + } +} + +export default class RenderTree { + declare tree: CapturedRenderNode[]; + declare owner: any; + declare retainObject: any; + declare releaseObject: any; + declare inspectNode: (node: Node) => void; + declare renderNodeIdPrefix: string; + declare nodes: Record; + declare serialized: Record; + declare ranges: Record; + declare parentNodes: any; + declare previouslyRetainedObjects: any; + declare retainedObjects: any; + declare inElementSupport: InElementSupportProvider | undefined; + /** + * Sets up the initial options. + * + * @param {Object} options + * - {owner} owner The Ember app's owner. + * - {Function} retainObject Called to retain an object for future inspection. + */ + constructor({ + owner, + retainObject, + releaseObject, + inspectNode, + }: { + owner: any; + retainObject: any; + releaseObject: any; + inspectNode: (node: Node) => void; + }) { + this.owner = owner; + this.retainObject = retainObject; + this.releaseObject = releaseObject; + this.inspectNode = inspectNode; + this._reset(); + try { + this.inElementSupport = new InElementSupportProvider(owner); + } catch (e) { + // eslint-disable-next-line no-console + console.error('failed to setup in element support', e); + // not supported + } + + // need to have different ids per application / iframe + // to distinguish the render nodes it in the inspector + // between apps + this.renderNodeIdPrefix = guidFor(this); + } + + /** + * Capture the render tree and serialize it for sending. + * + * This returns an array of `SerializedRenderNode`: + * + * type SerializedItem = string | number | bigint | boolean | null | undefined | { id: string }; + * + * interface SerializedRenderNode { + * id: string; + * type: 'outlet' | 'engine' | 'route-template' | 'component'; + * name: string; + * args: { + * named: Dict; + * positional: SerializedItem[]; + * }; + * instance: SerializedItem; + * template: Option; + * bounds: Option<'single' | 'range'>; + * children: SerializedRenderNode[]; + * } + */ + build() { + this._reset(); + + this.tree = captureRenderTree(this.owner); + let serialized = this._serializeRenderNodes(this.tree); + + this._releaseStaleObjects(); + + return serialized; + } + + /** + * Find a render node by id. + */ + find(id: string): CapturedRenderNode | null { + let node = this.nodes[id]; + + if (node) { + return this._serializeRenderNode(node); + } else { + return null; + } + } + + /** + * Find the deepest enclosing render node for a given DOM node. + * + * @param {Node} node A DOM node. + * @param {string} hint The id of the last-matched render node (see comment below). + * @return {Option} The deepest enclosing render node, if any. + */ + findNearest( + node: Node & { __emberInspectorParentElement: any; __emberInspectorParentNode: any }, + hint?: string + ) { + // Use the hint if we are given one. When doing "live" inspecting, the mouse likely + // hasn't moved far from its last location. Therefore, the matching render node is + // likely to be the same render node, one of its children, or its parent. Knowing this, + // we can heuristically start the search from the parent render node (which would also + // match against this node and its children), then only fallback to matching the entire + // tree when there is no match in this subtree. + + if (node.__emberInspectorParentElement) { + node = node.__emberInspectorParentElement; + } + + let hintNode = this._findUp(this.nodes[hint!]); + let hints = [hintNode!]; + if (node.__emberInspectorParentNode) { + const remoteNode = this.inElementSupport?.nodeMap.get(node); + const n = remoteNode && this.nodes[remoteNode]; + hints.push(n); + } + + hints = hints.filter((h) => Boolean(h)); + let renderNode; + + const remoteRoots = this.inElementSupport?.remoteRoots || []; + + renderNode = this._matchRenderNodes([...hints, ...remoteRoots, ...this.tree], node); + + if (renderNode) { + return this._serializeRenderNode(renderNode); + } else { + return null; + } + } + + /** + * Get the bounding rect for a given render node id. + * + * @param {*} id A render node id. + * @return {Option} The bounding rect, if the render node is found and has valid `bounds`. + */ + getBoundingClientRect(id: string) { + let node = this.nodes[id]; + + if (!node || !node.bounds) { + return null; + } + + // Element.getBoundingClientRect seems to be less buggy when it comes + // to taking hidden (clipped) content into account, so prefer that over + // Range.getBoundingClientRect when possible. + + let rect; + let { bounds } = node; + let { firstNode } = bounds; + + if (isSingleNode(bounds) && (firstNode as unknown as HTMLElement).getBoundingClientRect) { + rect = (firstNode as unknown as HTMLElement).getBoundingClientRect(); + } else { + rect = this.getRange(id)?.getBoundingClientRect(); + } + + if (rect && !isEmptyRect(rect)) { + return rect; + } + + return null; + } + + /** + * Get the DOM range for a give render node id. + * + * @param {string} id A render node id. + * @return {Option} The DOM range, if the render node is found and has valid `bounds`. + */ + getRange(id: string) { + let range = this.ranges[id]; + + if (range === undefined) { + let node = this.nodes[id]; + + if (node && node.bounds && isAttached(node.bounds)) { + range = document.createRange(); + range.setStartBefore(node.bounds.firstNode as unknown as Node); + range.setEndAfter(node.bounds.lastNode as unknown as Node); + } else { + // If the node has already been detached, we probably have a stale tree + range = null; + } + + this.ranges[id] = range; + } + + return range; + } + + /** + * Scroll the given render node id into view (if the render node is found and has valid `bounds`). + * + * @param {string} id A render node id. + */ + scrollIntoView(id: string) { + let node = this.nodes[id]; + + if (!node || node.bounds === null) { + return; + } + + let element = this._findNode(node, [Node.ELEMENT_NODE]); + + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + } + } + + /** + * Inspect the bounds for the given render node id in the "Elements" panel (if the render node + * is found and has valid `bounds`). + * + * @param {string} id A render node id. + */ + inspectElement(id: string) { + let node = this.nodes[id]; + + if (!node || node.bounds === null) { + return; + } + + // We cannot inspect text nodes + let target = this._findNode(node, [Node.ELEMENT_NODE, Node.COMMENT_NODE]); + + this.inspectNode(target); + } + + teardown() { + this._reset(); + this._releaseStaleObjects(); + } + + _reset() { + this.tree = []; + this.nodes = Object.create(null); + this.parentNodes = Object.create(null); + this.serialized = Object.create(null); + this.ranges = Object.create(null); + this.previouslyRetainedObjects = this.retainedObjects || new Map(); + this.retainedObjects = new Map(); + } + + _createSimpleInstance(name: string, args: any) { + const obj = Object.create(null); + obj.args = args; + obj.constructor = { + name: name, + comment: 'fake constructor', + }; + return obj; + } + + _insertHtmlElementNode(node: CapturedRenderNode, parentNode?: CapturedRenderNode | null): any { + const element = node.bounds!.firstNode as unknown as HTMLElement; + const htmlNode = { + id: node.id + 'html-element', + type: 'html-element', + name: element.tagName.toLowerCase(), + instance: element, + template: null, + bounds: { + firstNode: element, + lastNode: element, + parentElement: element.parentElement, + }, + args: { + named: {}, + positional: [], + }, + children: [], + } as unknown as CapturedRenderNode; + const idx = parentNode!.children.indexOf(node); + parentNode!.children.splice(idx, 0, htmlNode); + return this._serializeRenderNode(htmlNode, parentNode); + } + + _serializeRenderNodes(nodes: CapturedRenderNode[], parentNode: CapturedRenderNode | null = null) { + const mapped = []; + // nodes can be mutated during serialize, which is why we use indexing instead of .map + for (let i = 0; i < nodes.length; i++) { + mapped.push(this._serializeRenderNode(nodes[i]!, parentNode)); + } + return mapped; + } + + _serializeRenderNode(node: CapturedRenderNode, parentNode: CapturedRenderNode | null = null) { + if (!node.id.startsWith(this.renderNodeIdPrefix)) { + node.id = `${this.renderNodeIdPrefix}-${node.id}`; + } + let serialized = this.serialized[node.id]; + + if (serialized === undefined) { + this.nodes[node.id] = node; + if (node.type === 'keyword' && node.name === 'in-element') { + node.type = 'component'; + node.instance = { + args: node.args, + constructor: { + name: 'InElement', + }, + }; + } + + if ( + this.inElementSupport?.Wormhole && + node.instance instanceof this.inElementSupport.Wormhole.default + ) { + this.inElementSupport?.remoteRoots.push(node); + const bounds = node.bounds; + Object.defineProperty(node, 'bounds', { + get() { + const instance = node.instance as any; + if ((node.instance as any)._destination) { + return { + firstNode: instance._destination, + lastNode: instance._destination, + parentElement: instance._destination.parentElement, + }; + } + return bounds; + }, + }); + } + + if (parentNode) { + this.parentNodes[node.id] = parentNode; + } + + if ((node.type as string) === 'html-element') { + // show set attributes in inspector + const instance = node.instance as HTMLElement; + Array.from(instance.attributes).forEach((attr) => { + node.args.named[attr.nodeName] = attr.nodeValue; + }); + // move modifiers and components into the element children + parentNode!.children.forEach((child) => { + if ( + (child.bounds!.parentElement as unknown as HTMLElement) === instance || + child.meta?.parentElement === instance || + (child.type === 'modifier' && (child as any).bounds.firstNode === instance) + ) { + node.children.push(child); + } + }); + node.children.forEach((child) => { + const idx = parentNode!.children.indexOf(child); + if (idx >= 0) { + parentNode!.children.splice(idx, 1); + } + }); + } + + if (node.type === 'component' && !node.instance) { + if (node.name === '(unknown template-only component)' && node.template?.endsWith('.hbs')) { + node.name = node.template.split(/\\|\//).slice(-1)[0]!.slice(0, -'.hbs'.length); + } + node.instance = this._createSimpleInstance('TemplateOnlyComponent', node.args.named); + } + + if (node.type === 'modifier') { + node.name = node.name + ?.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + .replace(/^-/, '') + .replace('-modifier', ''); + node.instance = node.instance || this._createSimpleInstance(node.name, node.args); + (node.instance as any).toString = () => node.name; + if (parentNode!.instance !== node.bounds!.firstNode) { + return this._insertHtmlElementNode(node, parentNode); + } + } + + this.serialized[node.id] = serialized = { + ...node, + meta: null, + args: this._serializeArgs(node.args), + instance: this._serializeItem(node.instance), + bounds: this._serializeBounds(node.bounds), + children: this._serializeRenderNodes(node.children, node), + }; + } + + return serialized; + } + + _serializeArgs({ named, positional }: any) { + return { + named: this._serializeDict(named), + positional: this._serializeArray(positional), + }; + } + + _serializeBounds(bounds: any) { + if (bounds === null) { + return null; + } else if (isSingleNode(bounds)) { + return 'single'; + } else { + return 'range'; + } + } + + _serializeDict(dict: any) { + let result = Object.create(null); + + if ('__ARGS__' in dict) { + dict = dict['__ARGS__']; + } + + Object.keys(dict).forEach((key) => { + result[key] = this._serializeItem(dict[key]); + }); + + return result; + } + + _serializeArray(array: any[]) { + return array.map((item) => this._serializeItem(item)); + } + + _serializeItem(item: any) { + switch (typeof item) { + case 'string': + case 'number': + case 'bigint': + case 'boolean': + case 'undefined': + return item; + + default: + return item && this._serializeObject(item); + } + } + + _serializeObject(object: any) { + let id = this.previouslyRetainedObjects.get(object); + + if (id === undefined) { + id = this.retainObject(object); + } + + this.retainedObjects.set(object, id); + + return { id, type: typeof object, inspect: inspect(object) }; + } + + _releaseStaleObjects() { + // The object inspector already handles ref-counting. So doing the same + // bookkeeping here may seem redundant, and it is. However, in practice, + // calling `retainObject` and `dropObject` could be quite expensive and + // we call them a lot. Also, temporarily dropping the ref-count to 0 just + // to re-increment it later (which is what would happen if we release all + // current objects before the walk, then re-retain them as we walk the + // new tree) is especially bad, as it triggers the initialization and + // clean up logic on each of these objects. In my (GC's) opinion, the + // object inspector is likely overly eager and doing too much bookkeeping + // when we can be using weakmaps. Until we have a chance to revamp the + // object inspector, the logic here tries to reduce the number of retain + // and release calls by diffing the object set betweeen walks. Feel free + // to remove this code and revert to the old release-then-retain method + // when the object inspector is not slow anymore. + + let { previouslyRetainedObjects, retainedObjects, releaseObject } = this; + + // The object inspector should make its own GC async, but until then... + window.setTimeout(function () { + for (let [object, id] of previouslyRetainedObjects) { + if (!retainedObjects.has(object)) { + releaseObject(id); + } + } + }, 0); + + this.previouslyRetainedObjects = null; + } + + _getParent(id: string) { + return this.parentNodes[id] || null; + } + + _matchRenderNodes( + renderNodes: CapturedRenderNode[], + dom: Node, + deep = true + ): CapturedRenderNode | null { + let candidates = [...renderNodes]; + + while (candidates.length > 0) { + let candidate = candidates.shift()!; + let range = this.getRange(candidate.id); + const isAllowed = candidate.type !== 'modifier' && (candidate as any).type !== 'html-element'; + + if (!isAllowed) { + candidates.push(...candidate.children); + continue; + } + + if (isAllowed && range && range.isPointInRange(dom, 0)) { + // We may be able to find a more exact match in one of the children. + return this._matchRenderNodes(candidate.children, dom, false) || candidate; + } else if (!range || deep) { + // There are some edge cases of non-containing parent nodes (e.g. "worm + // hole") so we can't rule out the entire subtree just because the parent + // didn't match. However, we should come back to this subtree at the end + // since we are unlikely to find a match here. + candidates.push(...candidate.children); + } else { + // deep = false: In this case, we already found a matching parent, + // we are just trying to find a more precise match here. If the child + // does not contain the DOM node, we don't need to travese further. + } + } + + return null; + } + + _findNode(capturedNode: CapturedRenderNode, nodeTypes: number[]): HTMLBaseElement { + let node = capturedNode.bounds!.firstNode; + + do { + if (nodeTypes.indexOf(node.nodeType) > -1) { + return node as unknown as HTMLBaseElement; + } else { + node = node.nextSibling!; + } + } while (node && node !== capturedNode.bounds!.lastNode); + + return capturedNode.meta?.parentElement || capturedNode.bounds!.parentElement; + } + + _findUp(node?: CapturedRenderNode) { + // Find the first parent render node with a different enclosing DOM element. + // Usually, this is just the first parent render node, but there are cases where + // multiple render nodes share the same bounds (e.g. outlet -> route template). + let parentElement = node?.meta?.parentElement || node?.bounds?.parentElement; + + while (node && parentElement) { + let parentNode = this._getParent(node.id); + + if (parentNode) { + node = parentNode; + + if (parentElement === (node?.meta?.parentElement || node?.bounds?.parentElement)) { + continue; + } + } + + break; + } + + return node; + } +} + +function isSingleNode({ firstNode, lastNode }: any) { + return firstNode === lastNode; +} + +function isAttached({ firstNode, lastNode }: any) { + return firstNode.isConnected && lastNode.isConnected; +} + +function isEmptyRect({ x, y, width, height }: any) { + return x === 0 && y === 0 && width === 0 && height === 0; +} diff --git a/packages/@ember/debug/ember-inspector-support/main.ts b/packages/@ember/debug/ember-inspector-support/main.ts new file mode 100644 index 00000000000..2c7d5308b25 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/main.ts @@ -0,0 +1,179 @@ +import BasicAdapter from '@ember/debug/ember-inspector-support/adapters/basic'; +import Port from '@ember/debug/ember-inspector-support/port'; +import ObjectInspector from '@ember/debug/ember-inspector-support/object-inspector'; +import GeneralDebug from '@ember/debug/ember-inspector-support/general-debug'; +import RenderDebug from '@ember/debug/ember-inspector-support/render-debug'; +import ViewDebug from '@ember/debug/ember-inspector-support/view-debug'; +import RouteDebug from '@ember/debug/ember-inspector-support/route-debug'; +import DataDebug from '@ember/debug/ember-inspector-support/data-debug'; +import PromiseDebug from '@ember/debug/ember-inspector-support/promise-debug'; +import ContainerDebug from '@ember/debug/ember-inspector-support/container-debug'; +import DeprecationDebug from '@ember/debug/ember-inspector-support/deprecation-debug'; +import Session from '@ember/debug/ember-inspector-support/services/session'; + +import Application from '@ember/application'; +import { + guidFor, + setGuidPrefix, +} from '@ember/debug/ember-inspector-support/utils/ember/object/internals'; +import { run } from '@ember/runloop'; +import BaseObject from '@ember/debug/ember-inspector-support/utils/base-object'; +import Namespace from '@ember/application/namespace'; + +class EmberDebug extends BaseObject { + /** + * Set to true during testing. + * + * @type {Boolean} + * @default false + */ + isTesting = false; + private _application: any; + private owner: any; + private started!: boolean; + adapter!: BasicAdapter; + port!: Port; + generalDebug!: GeneralDebug; + objectInspector!: ObjectInspector; + + get applicationName() { + return this._application.name || this._application.modulePrefix; + } + + /** + * We use the application's id instead of the owner's id so that we use the same inspector + * instance for the same application even if it was reset (owner changes on reset). + */ + get applicationId() { + if (!this.isTesting) { + return guidFor(this._application, 'ember'); + } + return guidFor(this.owner, 'ember'); + } + + // Using object shorthand syntax here is somehow having strange side effects. + + Port = Port; + Adapter = BasicAdapter; + + start($keepAdapter: boolean) { + if (this.started) { + this.reset($keepAdapter); + return; + } + if (!this._application && !this.isTesting) { + this._application = getApplication(); + } + this.started = true; + + this.reset(); + + this.adapter.debug('Ember Inspector Active'); + this.adapter.sendMessage({ + type: 'inspectorLoaded', + }); + } + + destroyContainer() { + if (this.generalDebug) { + this.generalDebug.sendReset(); + } + [ + 'dataDebug', + 'viewDebug', + 'routeDebug', + 'generalDebug', + 'renderDebug', + 'promiseDebug', + 'containerDebug', + 'deprecationDebug', + 'objectInspector', + 'session', + ].forEach((prop) => { + let handler = (this as any)[prop]; + if (handler) { + run(handler, 'destroy'); + (this as any)[prop] = null; + } + }); + } + + startModule(prop: string, Module: any) { + (this as any)[prop] = new Module({ namespace: this }); + } + + willDestroy() { + this.destroyContainer(); + super.willDestroy(); + } + + reset($keepAdapter?: boolean) { + setGuidPrefix(Math.random().toString()); + if (!this.isTesting && !this.owner) { + this.owner = getOwner(this._application); + } + this.destroyContainer(); + run(() => { + // Adapters don't have state depending on the application itself. + // They also maintain connections with the inspector which we will + // lose if we destroy. + if (!this.adapter || !$keepAdapter) { + this.startModule('adapter', this.Adapter); + } + if (!this.port || !$keepAdapter) { + this.startModule('port', this.Port); + } + + this.startModule('session', Session); + this.startModule('generalDebug', GeneralDebug); + this.startModule('renderDebug', RenderDebug); + this.startModule('objectInspector', ObjectInspector); + this.startModule('routeDebug', RouteDebug); + this.startModule('viewDebug', ViewDebug); + this.startModule('dataDebug', DataDebug); + this.startModule('promiseDebug', PromiseDebug); + this.startModule('containerDebug', ContainerDebug); + this.startModule('deprecationDebug', DeprecationDebug); + + this.generalDebug.sendBooted(); + }); + } + + inspect(obj: any) { + this.objectInspector.sendObject(obj); + this.adapter.log('Sent to the Object Inspector'); + return obj; + } + + clear() { + Object.assign(this, { + _application: null, + owner: null, + }); + } +} + +function getApplication() { + let namespaces = Namespace.NAMESPACES; + let application; + + namespaces.forEach((namespace) => { + if (namespace instanceof Application) { + application = namespace; + return false; + } + return; + }); + return application; +} + +function getOwner(application: Application) { + if (application.autoboot) { + return application.__deprecatedInstance__; + } else if (application._applicationInstances /* Ember 3.1+ */) { + return [...application._applicationInstances][0]; + } + return null; +} + +export default EmberDebug; diff --git a/packages/@ember/debug/ember-inspector-support/object-inspector.ts b/packages/@ember/debug/ember-inspector-support/object-inspector.ts new file mode 100644 index 00000000000..3dc6df5267d --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/object-inspector.ts @@ -0,0 +1,1265 @@ +import DebugPort from './debug-port'; +import bound from '@ember/debug/ember-inspector-support/utils/bound-method'; +import { typeOf, inspect } from '@ember/debug/ember-inspector-support/utils/type-check'; +import { cacheFor } from '@ember/object/internals'; +import { _backburner, join } from '@ember/runloop'; +import EmberObject from '@ember/object'; +import Service from '@ember/service'; +import CoreObject from '@ember/object/core'; +import { meta as emberMeta } from '@ember/-internals/meta'; +import emberNames from './utils/ember-object-names'; +import getObjectName from './utils/get-object-name'; +import type { Tag } from '@glimmer/validator'; +import { + valueForTag as tagValue, + validateTag as tagValidate, + track, + tagFor, +} from '@glimmer/validator'; +import { isComputed, tagForProperty } from '@ember/-internals/metal'; +import { guidFor } from './utils/ember/object/internals'; +import type Mixin from '@ember/object/mixin'; +import ObjectProxy from '@ember/object/proxy'; +import ArrayProxy from '@ember/array/proxy'; +import Component from '@ember/component'; + +const keys = Object.keys; + +/** + * Determine the type and get the value of the passed property + * @param {*} object The parent object we will look for `key` on + * @param {string} key The key for the property which points to a computed, EmberObject, etc + * @param {*} computedValue A value that has already been computed with calculateCP + * @return {{inspect: (string|*), type: string}|{computed: boolean, inspect: string, type: string}|{inspect: string, type: string}} + */ +function inspectValue( + object: any, + key: string, + computedValue?: any +): { type: string; inspect: string; isCalculated?: boolean } { + let string; + const value = computedValue; + + if (arguments.length === 3 && computedValue === undefined) { + return { type: `type-undefined`, inspect: 'undefined' }; + } + + // TODO: this is not very clean. We should refactor calculateCP, etc, rather than passing computedValue + if (computedValue !== undefined) { + if (value instanceof HTMLElement) { + return { + type: 'type-object', + inspect: `<${value.tagName.toLowerCase()}>`, + }; + } + return { type: `type-${typeOf(value)}`, inspect: inspect(value) }; + } + + if (value instanceof EmberObject) { + return { type: 'type-ember-object', inspect: value.toString() }; + } else if (isComputed(object, key)) { + string = ''; + return { type: 'type-descriptor', inspect: string }; + } else if (value?.isDescriptor) { + return { type: 'type-descriptor', inspect: value.toString() }; + } else if (value instanceof HTMLElement) { + return { type: 'type-object', inspect: value.tagName.toLowerCase() }; + } else { + return { type: `type-${typeOf(value)}`, inspect: inspect(value) }; + } +} + +function isMandatorySetter(descriptor: PropertyDescriptor) { + if ( + descriptor.set && + Function.prototype.toString.call(descriptor.set).includes('You attempted to update') + ) { + return true; + } + return false; +} + +function getTagTrackedTags(tag: Tag, ownTag: Tag, level = 0) { + const props: Tag[] = []; + // do not include tracked properties from dependencies + if (!tag || level > 1) { + return props; + } + const subtags = Array.isArray(tag.subtag) ? tag.subtag : []; + if (tag.subtag && !Array.isArray(tag.subtag)) { + // if (tag.subtag._propertyKey) props.push(tag.subtag); + // TODO fetch tag metadata object and property key + props.push(...getTagTrackedTags(tag.subtag, ownTag, level + 1)); + } + if (subtags) { + subtags.forEach((t) => { + if (t === ownTag) return; + // if (t._propertyKey) props.push(t); + // TODO fetch tag metadata object and property key + props.push(...getTagTrackedTags(t, ownTag, level + 1)); + }); + } + return props; +} + +// TODO needs https://github.com/glimmerjs/glimmer-vm/pull/1489 +function getTrackedDependencies( + object: any, + property: string, + tagInfo: { tag: Tag; revision: number } +) { + const tag = tagInfo.tag; + const proto = Object.getPrototypeOf(object); + if (!proto) return []; + const cpDesc = emberMeta(object).peekDescriptors(property); + const dependentKeys = []; + if (cpDesc) { + dependentKeys.push(...(cpDesc._dependentKeys || []).map((k: string) => ({ name: k }))); + } + const ownTag = tagFor(object, property); + const tags = getTagTrackedTags(tag, ownTag); + const mapping: Record = {}; + let maxRevision = tagValue(tag); + tags.forEach(() => { + // TODO needs https://github.com/glimmerjs/glimmer-vm/pull/1489 + // const p = (t._object ? getObjectName(t._object) + '.' : '') + t._propertyKey; + // const [objName, prop] = p.split('.'); + // mapping[objName] = mapping[objName] || new Set(); + // const value = tagValue(t); + // if (prop) { + // mapping[objName].add([prop, value]); + // } + }); + const hasChange = (tagInfo.revision && maxRevision !== tagInfo.revision) || false; + const names = new Set(); + Object.entries(mapping).forEach(([objName, props]) => { + if (names.has(objName)) { + return; + } + names.add(objName); + if (props.length > 1) { + dependentKeys.push({ name: objName }); + props.forEach((p) => { + const changed = hasChange && p[1] > tagInfo.revision; + const obj = { + child: p[0], + changed: false, + }; + if (changed) { + obj.changed = true; + } + dependentKeys.push(obj); + }); + } + if (props.length === 1) { + const p = [...props][0]!; + const changed = hasChange && p[1] > tagInfo.revision; + const obj = { + name: objName + '.' + p[0], + changed: false, + }; + if (changed) { + obj.changed = true; + } + dependentKeys.push(obj); + } + if (props.length === 0) { + dependentKeys.push({ name: objName }); + } + }); + + return [...dependentKeys]; +} + +type DebugPropertyInfo = { + skipMixins: string[]; + skipProperties: string[]; + groups: { + name: string; + expand: boolean; + properties: string[]; + }[]; +}; + +type PropertyInfo = { + code: any; + isService: any; + dependentKeys: any; + isGetter: any; + isTracked: any; + isProperty: any; + isComputed: any; + auto: any; + readOnly: any; + isMandatorySetter: any; + canTrack: boolean; + name: any; + value: any; + isExpensive: boolean; + overridden: boolean; +}; + +type MixinDetails = { + id: string; + name: string; + properties: PropertyInfo[]; + expand?: boolean; + isEmberMixin?: boolean; +}; + +type TagInfo = { + revision: number; + tag: Tag; +}; + +export default class ObjectInspector extends DebugPort { + get adapter() { + return this.namespace?.adapter; + } + + currentObject: { + object: any; + mixinDetails: MixinDetails[]; + objectId: string; + } | null = null; + + updateCurrentObject() { + Object.values(this.sentObjects).forEach((obj) => { + if (obj instanceof CoreObject && obj.isDestroyed) { + this.dropObject(guidFor(obj)); + } + }); + if (this.currentObject) { + const { object, mixinDetails, objectId } = this.currentObject; + mixinDetails.forEach((mixin, mixinIndex) => { + mixin.properties.forEach((item) => { + if (item.overridden) { + return true; + } + try { + let cache = cacheFor(object, item.name); + if (item.isExpensive && !cache) return true; + if (item.value.type === 'type-function') return true; + + let value = null; + let changed = false; + const values = (this.objectPropertyValues[objectId] = + this.objectPropertyValues[objectId] || {}); + const tracked = (this.trackedTags[objectId] = this.trackedTags[objectId] || {}); + + const desc = Object.getOwnPropertyDescriptor(object, item.name); + const isSetter = desc && isMandatorySetter(desc); + + if (item.canTrack && !isSetter) { + let tagInfo = tracked[item.name] || { + tag: tagFor(object, item.name), + revision: 0, + }; + if (!tagInfo.tag) return false; + + changed = !tagValidate(tagInfo.tag, tagInfo.revision); + if (changed) { + tagInfo.tag = track(() => { + value = object.get?.(item.name) || object[item.name]; + }); + } + tracked[item.name] = tagInfo; + } else { + value = calculateCP(object, item, {}); + if (values[item.name] !== value) { + changed = true; + values[item.name] = value; + } + } + + if (changed) { + value = inspectValue(object, item.name, value) as any; + value.isCalculated = true; + let dependentKeys = null; + if (tracked[item.name]) { + dependentKeys = getTrackedDependencies(object, item.name, tracked[item.name]); + tracked[item.name].revision = tagValue(tracked[item.name].tag); + } + this.sendMessage('updateProperty', { + objectId, + property: + Array.isArray(object) && !Number.isNaN(parseInt(item.name)) + ? parseInt(item.name) + : item.name, + value, + mixinIndex, + dependentKeys, + }); + } + } catch { + // dont do anything + } + return false; + }); + }); + } + } + + init() { + super.init(); + this.sentObjects = {}; + _backburner.on('end', bound(this, this.updateCurrentObject)); + } + + willDestroy() { + super.willDestroy(); + for (let objectId in this.sentObjects) { + this.releaseObject(objectId); + } + _backburner.off('end', bound(this, this.updateCurrentObject)); + } + + sentObjects: Record = {}; + + parentObjects: Record = {}; + + objectPropertyValues: Record> = {}; + + trackedTags: Record> = {}; + + _errorsFor: Record> = {}; + + static { + this.prototype.portNamespace = 'objectInspector'; + this.prototype.messages = { + digDeeper(this: ObjectInspector, message: { objectId: any; property: any }) { + this.digIntoObject(message.objectId, message.property); + }, + releaseObject(this: ObjectInspector, message: { objectId: any }) { + this.releaseObject(message.objectId); + }, + calculate( + this: ObjectInspector, + message: { + objectId: string; + property: string; + mixinIndex: number; + isCalculated: boolean; + } + ) { + let value; + value = this.valueForObjectProperty(message.objectId, message.property, message.mixinIndex); + if (value) { + this.sendMessage('updateProperty', value); + message.isCalculated = true; + } + this.sendMessage('updateErrors', { + objectId: message.objectId, + errors: errorsToSend(this._errorsFor[message.objectId]!), + }); + }, + saveProperty( + this: ObjectInspector, + message: { value: any; dataType: string; objectId: string; property: string } + ) { + let value = message.value; + if (message.dataType && message.dataType === 'date') { + value = new Date(value); + } + this.saveProperty(message.objectId, message.property, value); + }, + sendToConsole(this: ObjectInspector, message: { objectId: any; property: string }) { + this.sendToConsole(message.objectId, message.property); + }, + gotoSource(this: ObjectInspector, message: { objectId: any; property: string }) { + this.gotoSource(message.objectId, message.property); + }, + sendControllerToConsole(this: ObjectInspector, message: { name: string }) { + const container = this.namespace?.owner; + this.sendValueToConsole(container.lookup(`controller:${message.name}`)); + }, + sendRouteHandlerToConsole(this: ObjectInspector, message: { name: string }) { + const container = this.namespace?.owner; + this.sendValueToConsole(container.lookup(`route:${message.name}`)); + }, + sendContainerToConsole(this: ObjectInspector) { + const container = this.namespace?.owner; + this.sendValueToConsole(container); + }, + /** + * Lookup the router instance, and find the route with the given name + * @param message The message sent + * @param {string} messsage.name The name of the route to lookup + */ + inspectRoute(this: ObjectInspector, message: { name: string }) { + const container = this.namespace?.owner; + const router = container.lookup('router:main'); + const routerLib = router._routerMicrolib || router.router; + // 3.9.0 removed intimate APIs from router + // https://github.com/emberjs/ember.js/pull/17843 + // https://deprecations.emberjs.com/v3.x/#toc_remove-handler-infos + // Ember >= 3.9.0 + this.sendObject(routerLib.getRoute(message.name)); + }, + inspectController(this: ObjectInspector, message: { name: string }) { + const container = this.namespace?.owner; + this.sendObject(container.lookup(`controller:${message.name}`)); + }, + inspectById(this: ObjectInspector, message: { objectId: string }) { + const obj = this.sentObjects[message.objectId]!; + if (obj) { + this.sendObject(obj); + } + }, + inspectByContainerLookup(this: ObjectInspector, message: { name: string }) { + const container = this.namespace?.owner; + this.sendObject(container.lookup(message.name)); + }, + traceErrors(this: ObjectInspector, message: { objectId: string }) { + let errors = this._errorsFor[message.objectId]!; + toArray(errors).forEach((error) => { + let stack = error.error; + if (stack && stack.stack) { + stack = stack.stack; + } else { + stack = error; + } + this.adapter.log(`Object Inspector error for ${error.property}`, stack); + }); + }, + }; + } + + canSend(val: any) { + return ( + val && + (val instanceof EmberObject || + val instanceof Object || + typeOf(val) === 'object' || + typeOf(val) === 'array') + ); + } + + saveProperty(objectId: string, prop: string, val: any) { + let object = this.sentObjects[objectId]; + join(() => { + if (object.set) { + object.set(prop, val); + } else { + object[prop] = val; + } + }); + } + + gotoSource(objectId: string, prop: string) { + let object = this.sentObjects[objectId]; + let value; + + if (prop === null || prop === undefined) { + value = this.sentObjects[objectId]; + } else { + value = calculateCP(object, { name: prop } as PropertyInfo, {}); + } + // for functions and classes we want to show the source + if (typeof value === 'function') { + this.adapter.inspectValue(value); + } + // use typeOf to distinguish basic objects/classes and Date, Error etc. + // objects like {...} have the constructor set to Object + if (typeOf(value) === 'object' && value.constructor !== Object) { + this.adapter.inspectValue(value.constructor); + } + } + + sendToConsole(objectId: string, prop?: string) { + let object = this.sentObjects[objectId]; + let value; + + if (prop === null || prop === undefined) { + value = this.sentObjects[objectId]; + } else { + value = calculateCP(object, { name: prop } as PropertyInfo, {}); + } + + this.sendValueToConsole(value); + } + + sendValueToConsole(value: any) { + (window as any).$E = value; + if (value instanceof Error) { + value = value.stack; + } + let args = [value]; + if (value instanceof EmberObject) { + args.unshift(inspect(value)); + } + this.adapter.log('Ember Inspector ($E): ', ...args); + } + + digIntoObject(objectId: string, property: string) { + let parentObject = this.sentObjects[objectId]; + let object = calculateCP(parentObject, { name: property } as PropertyInfo, {}); + + if (this.canSend(object)) { + const currentObject = this.currentObject; + let details = this.mixinsForObject(object); + this.parentObjects[details.objectId] = currentObject; + this.sendMessage('updateObject', { + parentObject: objectId, + property, + objectId: details.objectId, + name: getObjectName(object), + details: details.mixins, + errors: details.errors, + }); + } + } + + sendObject(object: any) { + if (!this.canSend(object)) { + throw new Error(`Can't inspect ${object}. Only Ember objects and arrays are supported.`); + } + let details = this.mixinsForObject(object); + this.sendMessage('updateObject', { + objectId: details.objectId, + name: getObjectName(object), + details: details.mixins, + errors: details.errors, + }); + } + + retainObject(object: any) { + let meta = emberMeta(object) as any; + let guid = guidFor(object); + + meta._debugReferences = meta._debugReferences || 0; + meta._debugReferences++; + + this.sentObjects[guid] = object; + + return guid; + } + + releaseObject(objectId: string) { + let object = this.sentObjects[objectId]; + if (!object) { + return; + } + let meta = emberMeta(object) as any; + let guid = guidFor(object); + + meta._debugReferences--; + + if (meta._debugReferences === 0) { + this.dropObject(guid); + } + } + + dropObject(objectId: string) { + if (this.parentObjects[objectId]) { + this.currentObject = this.parentObjects[objectId]; + } + delete this.parentObjects[objectId]; + + delete this.sentObjects[objectId]; + delete this.objectPropertyValues[objectId]; + delete this.trackedTags[objectId]; + if (this.currentObject && this.currentObject.objectId === objectId) { + this.currentObject = null; + } + + delete this._errorsFor[objectId]; + + this.sendMessage('droppedObject', { objectId }); + } + + /** + * This function, and the rest of Ember Inspector, currently refer to the + * output entirely as mixins. However, this is no longer accurate! This has + * been refactored to return a list of objects that represent both the classes + * themselves and their mixins. For instance, the following class definitions: + * + * ```js + * class Foo extends EmberObject {} + * + * class Bar extends Foo {} + * + * class Baz extends Bar.extend(Mixin1, Mixin2) {} + * + * let obj = Baz.create(); + * ``` + * + * Will result in this in the inspector: + * + * ``` + * - Own Properties + * - Baz + * - Mixin1 + * - Mixin2 + * - Bar + * - Foo + * - EmberObject + * ``` + * + * The "mixins" returned by this function directly represent these things too. + * Each class object consists of the actual own properties of that class's + * prototype, and is followed by the mixins (if any) that belong to that + * class. Own Properties represents the actual own properties of the object + * itself. + * + * TODO: The rest of the Inspector should be updated to reflect this new data + * model, and these functions should be updated with new names. Mixins should + * likely be embedded _on_ the class definitions, but this was designed to be + * backwards compatible. + */ + mixinDetailsForObject(object: any): MixinDetails[] { + const mixins = []; + + const own = ownMixins(object); + + const objectMixin = { + id: guidFor(object), + name: getObjectName(object), + properties: ownProperties(object, own), + } as MixinDetails; + + mixins.push(objectMixin); + + // insert ember mixins + for (let mixin of own) { + let name = (mixin.ownerConstructor || emberNames.get(mixin) || '').toString(); + + if (!name && typeof mixin.toString === 'function') { + try { + name = mixin.toString(); + + if (name === '(unknown)') { + name = '(unknown mixin)'; + } + } catch { + name = '(Unable to convert Object to string)'; + } + } + + const mix = { + properties: propertiesForMixin(mixin), + name, + isEmberMixin: true, + id: guidFor(mixin), + }; + + mixins.push(mix); + } + + const proto = Object.getPrototypeOf(object); + + if (proto && proto !== Object.prototype) { + mixins.push(...this.mixinDetailsForObject(proto)); + } + + return mixins; + } + + mixinsForObject(object: any) { + if (object instanceof ObjectProxy && object.content && !(object as any)._showProxyDetails) { + object = object.content; + } + + if (object instanceof ArrayProxy && object.content && !(object as any)._showProxyDetails) { + object = object.slice(0, 101); + } + + let mixinDetails = this.mixinDetailsForObject(object); + + mixinDetails[0]!.name = 'Own Properties'; + mixinDetails[0]!.expand = true; + + if (mixinDetails[1] && !mixinDetails[1].isEmberMixin) { + mixinDetails[1].expand = true; + } + + fixMandatorySetters(mixinDetails); + applyMixinOverrides(mixinDetails); + + let debugPropertyInfo = null; + let debugInfo = getDebugInfo(object); + if (debugInfo) { + debugPropertyInfo = getDebugInfo(object).propertyInfo; + mixinDetails = customizeProperties(mixinDetails, debugPropertyInfo); + } + + let expensiveProperties = null; + if (debugPropertyInfo) { + expensiveProperties = debugPropertyInfo.expensiveProperties; + } + + let objectId = this.retainObject(object); + + let errorsForObject = (this._errorsFor[objectId] = {}); + const tracked = (this.trackedTags[objectId] = this.trackedTags[objectId] || {}); + calculateCPs(object, mixinDetails, errorsForObject, expensiveProperties, tracked); + + this.currentObject = { object, mixinDetails, objectId }; + + let errors = errorsToSend(errorsForObject); + return { objectId, mixins: mixinDetails, errors }; + } + + valueForObjectProperty(objectId: string, property: string, mixinIndex: number) { + let object = this.sentObjects[objectId], + value; + + if (object.isDestroying) { + value = ''; + } else { + value = calculateCP(object, { name: property } as PropertyInfo, this._errorsFor[objectId]!); + } + + if (!value || !(value instanceof CalculateCPError)) { + value = inspectValue(object, property, value); + value.isCalculated = true; + + return { objectId, property, value, mixinIndex }; + } + + return null; + } + + inspect = inspect; + inspectValue = inspectValue; +} + +function ownMixins(object: any) { + // TODO: We need to expose an API for getting _just_ the own mixins directly + let meta = emberMeta(object); + let parentMeta = meta.parent; + let mixins = new Set(); + + // Filter out anonymous mixins that are directly in a `class.extend` + let baseMixins = + object.constructor && + object.constructor.PrototypeMixin && + object.constructor.PrototypeMixin.mixins; + + meta.forEachMixins((m: Mixin) => { + // Find mixins that: + // - Are not in the parent classes + // - Are not primitive (has mixins, doesn't have properties) + // - Don't include any of the base mixins from a class extend + if ( + (!parentMeta || !parentMeta.hasMixin(m)) && + !m.properties && + m.mixins && + (!baseMixins || !m.mixins.some((m) => baseMixins.includes(m))) + ) { + mixins.add(m); + } + }); + + return mixins; +} + +function ownProperties(object: any, ownMixins: Set) { + let meta = emberMeta(object); + + if (Array.isArray(object)) { + // slice to max 101, for performance and so that the object inspector will show a `more items` indicator above 100 + object = object.slice(0, 101); + } + + let props = Object.getOwnPropertyDescriptors(object) as any; + delete props.constructor; + + // meta has the correct descriptors for CPs + meta.forEachDescriptors((name, desc) => { + // only for own properties + if (props[name]) { + props[name] = desc; + } + }); + + // remove properties set by mixins + // especially for Object.extend(mixin1, mixin2), where a new class is created which holds the merged properties + // if all properties are removed, it will be marked as useless mixin and will not be shown + ownMixins.forEach((m) => { + if (m.mixins) { + m.mixins.forEach((mix) => { + Object.keys(mix.properties || {}).forEach((k) => { + const pDesc = Object.getOwnPropertyDescriptor(mix.properties, k) as PropertyDescriptor & { + _getter: () => any; + }; + if (pDesc && props[k] && pDesc.get && pDesc.get === props[k].get) { + delete props[k]; + } + if (pDesc && props[k] && 'value' in pDesc && pDesc.value === props[k].value) { + delete props[k]; + } + if (pDesc && props[k] && pDesc._getter === props[k]._getter) { + delete props[k]; + } + }); + }); + } + }); + + Object.keys(props).forEach((k) => { + if (typeof props[k].value === 'function') { + return; + } + props[k].isDescriptor = true; + }); + + // Clean the properties, removing private props and bindings, etc + return addProperties([], props); +} + +function propertiesForMixin(mixin: Mixin) { + let properties: PropertyInfo[] = []; + + if (mixin.mixins) { + mixin.mixins.forEach((mixin) => { + if (mixin.properties) { + addProperties(properties, mixin.properties); + } + }); + } + + return properties; +} + +function addProperties(properties: unknown[], hash: any) { + for (let prop in hash) { + if (!Object.prototype.hasOwnProperty.call(hash, prop)) { + continue; + } + + if (isInternalProperty(prop)) { + continue; + } + + // remove `fooBinding` type props + if (prop.match(/Binding$/)) { + continue; + } + + // when mandatory setter is removed, an `undefined` value may be set + const desc = Object.getOwnPropertyDescriptor(hash, prop) as any; + if (!desc) continue; + if (hash[prop] === undefined && desc.value === undefined && !desc.get && !desc._getter) { + continue; + } + + let options = { + isMandatorySetter: isMandatorySetter(desc), + isService: false, + isComputed: false, + code: undefined as string | undefined, + dependentKeys: undefined, + isCalculated: undefined as boolean | undefined, + readOnly: undefined as boolean | undefined, + auto: undefined as boolean | undefined, + canTrack: undefined as boolean | undefined, + isGetter: undefined as boolean | undefined, + isTracked: undefined as boolean | undefined, + isProperty: undefined as boolean | undefined, + }; + + if (typeof hash[prop] === 'object' && hash[prop] !== null) { + options.isService = !('type' in hash[prop]) && hash[prop].type === 'service'; + + if (!options.isService) { + if (hash[prop].constructor) { + options.isService = hash[prop].constructor.isServiceFactory; + } + } + + if (!options.isService) { + options.isService = desc.value instanceof Service; + } + } + if (options.isService) { + replaceProperty(properties, prop, inspectValue(hash, prop), options); + continue; + } + + if (isComputed(hash, prop)) { + options.isComputed = true; + options.dependentKeys = (desc._dependentKeys || []).map((key: string) => key.toString()); + + if (typeof desc.get === 'function') { + options.code = Function.prototype.toString.call(desc.get); + } + if (typeof desc._getter === 'function') { + options.isCalculated = true; + options.code = Function.prototype.toString.call(desc._getter); + } + if (!options.code) { + options.code = ''; + } + + options.readOnly = desc._readOnly; + options.auto = desc._auto; + options.canTrack = options.code !== ''; + } + + if (desc.get) { + options.isGetter = true; + options.canTrack = true; + if (!desc.set) { + options.readOnly = true; + } + } + if (Object.prototype.hasOwnProperty.call(desc, 'value') || options.isMandatorySetter) { + delete options.isGetter; + delete options.isTracked; + options.isProperty = true; + options.canTrack = false; + } + replaceProperty(properties, prop, inspectValue(hash, prop), options); + } + + return properties; +} + +function isInternalProperty(property: string) { + if ( + [ + '_state', + '_states', + '_target', + '_currentState', + '_super', + '_debugContainerKey', + '_transitionTo', + '_debugInfo', + '_showProxyDetails', + ].includes(property) + ) { + return true; + } + + let isInternalProp = [ + '__LEGACY_OWNER', + '__ARGS__', + '__HAS_BLOCK__', + '__PROPERTY_DID_CHANGE__', + ].some((internalProp) => property.startsWith(internalProp)); + + return isInternalProp; +} + +function replaceProperty(properties: any, name: string, value: any, options: any) { + let found; + + let i, l; + for (i = 0, l = properties.length; i < l; i++) { + if (properties[i].name === name) { + found = i; + break; + } + } + + if (found) { + properties.splice(i, 1); + } + + let prop = { name, value } as PropertyInfo; + prop.isMandatorySetter = options.isMandatorySetter; + prop.readOnly = options.readOnly; + prop.auto = options.auto; + prop.canTrack = options.canTrack; + prop.isComputed = options.isComputed; + prop.isProperty = options.isProperty; + prop.isTracked = options.isTracked; + prop.isGetter = options.isGetter; + prop.dependentKeys = options.dependentKeys || []; + let hasServiceFootprint = + prop.value && typeof prop.value.inspect === 'string' + ? prop.value.inspect.includes('@service:') + : false; + prop.isService = options.isService || hasServiceFootprint; + prop.code = options.code; + properties.push(prop); +} + +function fixMandatorySetters(mixinDetails: MixinDetails[]) { + let seen: any = {}; + let propertiesToRemove: any = []; + + mixinDetails.forEach((detail, detailIdx) => { + detail.properties.forEach((property) => { + if (property.isMandatorySetter) { + seen[property.name] = { + name: property.name, + value: property.value.inspect, + detailIdx, + property, + }; + } else if (Object.prototype.hasOwnProperty.call(seen, property.name) && seen[property.name]) { + propertiesToRemove.push(seen[property.name]); + delete seen[property.name]; + } + }); + }); + + propertiesToRemove.forEach((prop: any) => { + let detail = mixinDetails[prop.detailIdx]!; + let index = detail.properties.indexOf(prop.property); + if (index !== -1) { + detail.properties.splice(index, 1); + } + }); +} + +function applyMixinOverrides(mixinDetails: MixinDetails[]) { + let seen: any = {}; + mixinDetails.forEach((detail) => { + detail.properties.forEach((property) => { + if (Object.prototype.hasOwnProperty.call(Object.prototype, property.name)) { + return; + } + + if (seen[property.name]) { + property.overridden = seen[property.name]; + delete property.value.isCalculated; + } + + seen[property.name] = detail.name; + }); + }); +} + +function calculateCPs( + object: any, + mixinDetails: MixinDetails[], + errorsForObject: Record, + expensiveProperties: string[], + tracked: Record +) { + expensiveProperties = expensiveProperties || []; + mixinDetails.forEach((mixin) => { + mixin.properties.forEach((item) => { + if (item.overridden) { + return true; + } + if (!item.value.isCalculated) { + let cache = cacheFor(object, item.name); + item.isExpensive = expensiveProperties.indexOf(item.name) >= 0; + if (cache !== undefined || !item.isExpensive) { + let value; + if (item.canTrack) { + tracked[item.name] = tracked[item.name] || ({} as TagInfo); + const tagInfo = tracked[item.name]!; + tagInfo.tag = track(() => { + value = calculateCP(object, item, errorsForObject); + }); + if (tagInfo.tag === tagForProperty(object, item.name)) { + if (!item.isComputed && !item.isService) { + item.code = ''; + item.isTracked = true; + } + } + item.dependentKeys = getTrackedDependencies(object, item.name, tagInfo); + tagInfo.revision = tagValue(tagInfo.tag); + } else { + value = calculateCP(object, item, errorsForObject); + } + if (!value || !(value instanceof CalculateCPError)) { + item.value = inspectValue(object, item.name, value); + item.value.isCalculated = true; + if (item.value.type === 'type-function') { + item.code = ''; + } + } + } + } + return; + }); + }); +} + +/** + Customizes an object's properties + based on the property `propertyInfo` of + the object's `_debugInfo` method. + + Possible options: + - `groups` An array of groups that contains the properties for each group + For example: + ```javascript + groups: [ + { name: 'Attributes', properties: ['firstName', 'lastName'] }, + { name: 'Belongs To', properties: ['country'] } + ] + ``` + - `includeOtherProperties` Boolean, + - `true` to include other non-listed properties, + - `false` to only include given properties + - `skipProperties` Array containing list of properties *not* to include + - `skipMixins` Array containing list of mixins *not* to include + - `expensiveProperties` An array of computed properties that are too expensive. + Adding a property to this array makes sure the CP is not calculated automatically. + + Example: + ```javascript + { + propertyInfo: { + includeOtherProperties: true, + skipProperties: ['toString', 'send', 'withTransaction'], + skipMixins: [ 'Ember.Evented'], + calculate: ['firstName', 'lastName'], + groups: [ + { + name: 'Attributes', + properties: [ 'id', 'firstName', 'lastName' ], + expand: true // open by default + }, + { + name: 'Belongs To', + properties: [ 'maritalStatus', 'avatar' ], + expand: true + }, + { + name: 'Has Many', + properties: [ 'phoneNumbers' ], + expand: true + }, + { + name: 'Flags', + properties: ['isLoaded', 'isLoading', 'isNew', 'isDirty'] + } + ] + } + } + ``` + */ +function customizeProperties(mixinDetails: MixinDetails[], propertyInfo: DebugPropertyInfo) { + let newMixinDetails: MixinDetails[] = []; + let neededProperties: Record = {}; + let groups = propertyInfo.groups || []; + let skipProperties = propertyInfo.skipProperties || []; + let skipMixins = propertyInfo.skipMixins || []; + + if (groups.length) { + mixinDetails[0]!.expand = false; + } + + groups.forEach((group) => { + group.properties.forEach((prop) => { + neededProperties[prop] = true; + }); + }); + + mixinDetails.forEach((mixin) => { + let newProperties: PropertyInfo[] = []; + mixin.properties.forEach((item) => { + if (skipProperties.indexOf(item.name) !== -1) { + return true; + } + + if ( + !item.overridden && + Object.prototype.hasOwnProperty.call(neededProperties, item.name) && + neededProperties[item.name] + ) { + neededProperties[item.name] = item; + } else { + newProperties.push(item); + } + return; + }); + mixin.properties = newProperties; + if (mixin.properties.length === 0 && mixin.name.toLowerCase().includes('unknown')) { + // nothing useful for this mixin + return; + } + if (skipMixins.indexOf(mixin.name) === -1) { + newMixinDetails.push(mixin); + } + }); + + groups + .slice() + .reverse() + .forEach((group) => { + let newMixin = { + name: group.name, + expand: group.expand, + properties: [] as PropertyInfo[], + } as MixinDetails; + group.properties.forEach(function (prop) { + // make sure it's not `true` which means property wasn't found + if (neededProperties[prop] !== true) { + newMixin.properties.push(neededProperties[prop] as PropertyInfo); + } + }); + newMixinDetails.unshift(newMixin); + }); + + return newMixinDetails; +} + +function getDebugInfo(object: any) { + let debugInfo = null; + let objectDebugInfo = object._debugInfo; + if (objectDebugInfo && typeof objectDebugInfo === 'function') { + if (object instanceof ObjectProxy && object.content) { + object = object.content; + } + debugInfo = objectDebugInfo.call(object); + } + debugInfo = debugInfo || {}; + let propertyInfo = debugInfo.propertyInfo || (debugInfo.propertyInfo = {}); + let skipProperties = (propertyInfo.skipProperties = + propertyInfo.skipProperties || (propertyInfo.skipProperties = [])); + + skipProperties.push('isDestroyed', 'isDestroying', 'container'); + // 'currentState' and 'state' are un-observable private properties. + // The rest are skipped to reduce noise in the inspector. + if (Component && object instanceof Component) { + skipProperties.push( + 'currentState', + 'state', + 'buffer', + 'outletSource', + 'lengthBeforeRender', + 'lengthAfterRender', + 'template', + 'layout', + 'templateData', + 'domManager', + 'states', + 'element', + 'targetObject' + ); + } else if (object?.constructor?.name === 'GlimmerDebugComponent') { + // These properties don't really exist on Glimmer Components, but + // reading their values trigger a development mode assertion. The + // more correct long term fix is to make getters lazy (shows "..." + // in the UI and only computed them when requested (when the user + // clicked on the "..." in the UI). + skipProperties.push('bounds', 'debugName', 'element'); + } + return debugInfo; +} + +function toArray(errors: Record) { + return keys(errors).map((key) => errors[key]); +} + +function calculateCP(object: any, item: PropertyInfo, errorsForObject: Record) { + const property = item.name; + delete errorsForObject[property]; + try { + if (object instanceof ArrayProxy && property == parseInt(property)) { + return object.objectAt(property); + } + return item.isGetter || property.includes?.('.') + ? object[property] + : object.get?.(property) || object[property]; // need to use `get` to be able to detect tracked props + } catch (error) { + errorsForObject[property] = { property, error }; + return new CalculateCPError(); + } +} + +class CalculateCPError {} + +function errorsToSend(errors: Record) { + return toArray(errors).map((error) => ({ property: error.property })); +} diff --git a/packages/@ember/debug/ember-inspector-support/render-debug.ts b/packages/@ember/debug/ember-inspector-support/render-debug.ts new file mode 100644 index 00000000000..62c2cdaf757 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/render-debug.ts @@ -0,0 +1,108 @@ +import DebugPort from './debug-port'; +import ProfileManager from './models/profile-manager'; + +import { _backburner } from '@ember/runloop'; +import bound from '@ember/debug/ember-inspector-support/utils/bound-method'; +import { subscribe } from '@ember/instrumentation'; + +// Initial setup, that has to occur before the EmberObject init for some reason +let profileManager = new ProfileManager(); +_subscribeToRenderEvents(); + +export default class RenderDebug extends DebugPort { + declare profileManager: ProfileManager; + constructor(data?: any) { + super(data); + this.profileManager = profileManager; + this.profileManager.setup(); + this.profileManager.wrapForErrors = (context, callback) => + this.port.wrap(() => callback.call(context)); + _backburner.on('end', bound(this, this._updateComponentTree)); + } + + willDestroy() { + super.willDestroy(); + + this.profileManager.wrapForErrors = function (context, callback) { + return callback.call(context); + }; + + this.profileManager.offProfilesAdded(this, this.sendAdded); + this.profileManager.teardown(); + + _backburner.off('end', bound(this, this._updateComponentTree)); + } + + sendAdded(profiles: any) { + this.sendMessage('profilesAdded', { + profiles, + isHighlightSupported: this.profileManager.isHighlightEnabled, + }); + } + + /** + * Update the components tree. Called on each `render.component` event. + * @private + */ + _updateComponentTree() { + this.namespace?.viewDebug?.sendTree(); + } + + static { + this.prototype.portNamespace = 'render'; + this.prototype.messages = { + clear(this: RenderDebug) { + this.profileManager.clearProfiles(); + this.sendMessage('profilesUpdated', { profiles: [] }); + }, + + releaseProfiles(this: RenderDebug) { + this.profileManager.offProfilesAdded(this, this.sendAdded); + }, + + watchProfiles(this: RenderDebug) { + this.sendMessage('profilesAdded', { + profiles: this.profileManager.profiles, + }); + this.profileManager.onProfilesAdded(this, this.sendAdded); + }, + + updateShouldHighlightRender( + this: RenderDebug, + { shouldHighlightRender }: { shouldHighlightRender: boolean } + ) { + this.profileManager.shouldHighlightRender = shouldHighlightRender; + }, + }; + } +} + +/** + * This subscribes to render events, so every time the page rerenders, it will push a new profile + * @return {*} + * @private + */ +function _subscribeToRenderEvents() { + subscribe('render', { + before(_name: string, timestamp: number, payload: any) { + const info = { + type: 'began', + timestamp, + payload, + now: Date.now(), + }; + return profileManager.addToQueue(info); + }, + + after(_name: string, timestamp, payload: any, beganIndex) { + const endedInfo = { + type: 'ended', + timestamp, + payload, + }; + + const index = profileManager.addToQueue(endedInfo); + profileManager.queue[beganIndex]!.endedIndex = index; + }, + }); +} diff --git a/packages/@ember/debug/ember-inspector-support/route-debug.ts b/packages/@ember/debug/ember-inspector-support/route-debug.ts new file mode 100644 index 00000000000..f56f4c8af37 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/route-debug.ts @@ -0,0 +1,310 @@ +import DebugPort from './debug-port'; +import classify from '@ember/debug/ember-inspector-support/utils/classify'; +import dasherize from '@ember/debug/ember-inspector-support/utils/dasherize'; +import { _backburner, later } from '@ember/runloop'; +import bound from '@ember/debug/ember-inspector-support/utils/bound-method'; + +const { hasOwnProperty } = Object.prototype; + +export default class RouteDebug extends DebugPort { + _cachedRouteTree = null; + private declare __currentURL: any; + private declare __currentRouter: any; + init() { + super.init(); + this.__currentURL = this.currentURL; + this.__currentRouter = this.router; + _backburner.on('end', bound(this, this.checkForUpdate)); + } + + checkForUpdate() { + if (this.__currentURL !== this.currentURL) { + this.sendCurrentRoute(); + this.__currentURL = this.currentURL; + } + if (this.__currentRouter !== this.router) { + this._cachedRouteTree = null; + this.__currentRouter = this.router; + } + } + + willDestroy() { + _backburner.off('end', bound(this, this.checkForUpdate)); + super.willDestroy(); + } + + get router() { + if (this.namespace?.owner.isDestroyed || this.namespace?.owner.isDestroying) { + return null; + } + return this.namespace?.owner.lookup('router:main'); + } + + get currentPath() { + return this.namespace?.owner.router.currentPath; + } + get currentURL() { + return this.namespace?.owner.router.currentURL; + } + + get emberCliConfig() { + return this.namespace?.generalDebug.emberCliConfig; + } + + static { + this.prototype.portNamespace = 'route'; + this.prototype.messages = { + getTree(this: RouteDebug) { + this.sendTree(); + }, + getCurrentRoute(this: RouteDebug) { + this.sendCurrentRoute(); + }, + }; + } + + sendCurrentRoute() { + const { currentPath: name, currentURL: url } = this; + later(() => { + this.sendMessage('currentRoute', { name, url }); + }, 50); + } + + get routeTree() { + if (this.namespace?.owner.isDestroyed || this.namespace?.owner.isDestroying) { + return null; + } + if (!this._cachedRouteTree && this.router) { + const router = this.router; + const routerLib = router._routerMicrolib || router.router; + let routeNames = routerLib.recognizer.names; + let routeTree: Record = {}; + for (let routeName in routeNames) { + if (!hasOwnProperty.call(routeNames, routeName)) { + continue; + } + let route = routeNames[routeName]; + this.buildSubTree(routeTree, route); + } + this._cachedRouteTree = arrayizeChildren({ children: routeTree }); + } + return this._cachedRouteTree; + } + + sendTree() { + let routeTree; + let error; + try { + routeTree = this.routeTree; + } catch (e: any) { + error = e.message; + } + this.sendMessage('routeTree', { tree: routeTree, error }); + } + + getClassName(name: string, type: string) { + let container = this.namespace.owner; + let resolver = container.application.__registry__.resolver; + let prefix = this.emberCliConfig?.modulePrefix; + let podPrefix = this.emberCliConfig?.podModulePrefix; + let usePodsByDefault = this.emberCliConfig?.usePodsByDefault; + let className; + if (prefix || podPrefix) { + // Uses modules + name = dasherize(name); + let fullName = `${type}:${name}`; + if (resolver.lookupDescription) { + className = resolver.lookupDescription(fullName); + } else if (resolver.describe) { + className = resolver.describe(fullName); + } + if (className === fullName) { + // full name returned as is - this resolver does not look for the module. + className = className.replace(new RegExp(`^${type}:`), ''); + } else if (className) { + // Module exists and found + className = className.replace(new RegExp(`^/?(${prefix}|${podPrefix})/${type}s/`), ''); + } else { + // Module does not exist + if (usePodsByDefault) { + // we don't include the prefix since it's redundant + // and not part of the file path. + // (podPrefix - prefix) is part of the file path. + let currentPrefix = ''; + if (podPrefix) { + currentPrefix = podPrefix.replace(new RegExp(`^/?${prefix}/?`), ''); + } + className = `${currentPrefix}/${name}/${type}`; + } else { + className = name.replace(/\./g, '/'); + } + } + className = className.replace(/\./g, '/'); + } else { + // No modules + if (type !== 'template') { + className = classify(`${name.replace(/\./g, '_')}_${type}`); + } else { + className = name.replace(/\./g, '/'); + } + } + return className; + } + + buildSubTree(routeTree: Record, route: { handlers: any; segments: string[] }) { + let handlers = route.handlers; + let owner = this.namespace.owner; + let subTree = routeTree; + let item; + let routeClassName; + let routeHandler; + let controllerName; + let controllerClassName; + let templateName; + let controllerFactory; + + for (let i = 0; i < handlers.length; i++) { + item = handlers[i]; + let handler = item.handler; + if (handler.match(/(loading|error)$/)) { + // make sure it has been defined before calling `getHandler` because + // we don't want to generate sub routes as this has side-effects. + if (!routeHasBeenDefined(owner, handler)) { + continue; + } + } + + if (subTree[handler] === undefined) { + routeClassName = this.getClassName(handler, 'route'); + + const router = this.router; + const routerLib = router._routerMicrolib || router.router; + // 3.9.0 removed intimate APIs from router + // https://github.com/emberjs/ember.js/pull/17843 + // https://deprecations.emberjs.com/v3.x/#toc_remove-handler-infos + routeHandler = routerLib.getRoute(handler); + + // Skip when route is an unresolved promise + if (typeof routeHandler?.then === 'function') { + // ensure we rebuild the route tree when this route is resolved + routeHandler.then(() => (this._cachedRouteTree = null)); + controllerName = '(unresolved)'; + controllerClassName = '(unresolved)'; + templateName = '(unresolved)'; + } else { + const get = + routeHandler.get || + function (this: any, prop: any) { + return this[prop]; + }; + controllerName = get.call(routeHandler, 'controllerName') || routeHandler.routeName; + controllerFactory = owner.factoryFor + ? owner.factoryFor(`controller:${controllerName}`) + : owner._lookupFactory(`controller:${controllerName}`); + controllerClassName = this.getClassName(controllerName, 'controller'); + templateName = this.getClassName(handler, 'template'); + } + + subTree[handler] = { + value: { + name: handler, + routeHandler: { + className: routeClassName, + name: handler, + }, + controller: { + className: controllerClassName, + name: controllerName, + exists: Boolean(controllerFactory), + }, + template: { + name: templateName, + }, + }, + }; + + if (i === handlers.length - 1) { + // it is a route, get url + subTree[handler].value.url = getURL(owner, route.segments); + subTree[handler].value.type = 'route'; + } else { + // it is a resource, set children object + subTree[handler].children = {}; + subTree[handler].value.type = 'resource'; + } + } + subTree = subTree[handler].children; + } + } +} + +function arrayizeChildren(routeTree: { value?: any; children: Record }) { + let obj: any = {}; + // Top node doesn't have a value + if (routeTree.value) { + obj.value = routeTree.value; + } + + if (routeTree.children) { + let childrenArray = []; + for (let i in routeTree.children) { + let route = routeTree.children[i]; + childrenArray.push(arrayizeChildren(route)); + } + obj.children = childrenArray; + } + + return obj; +} + +/** + * + * @param {*} container + * @param {*} segments + * @return {String} + */ +function getURL(container: any, segments: any) { + const locationImplementation = container.lookup('router:main').location; + let url: string[] = []; + for (let i = 0; i < segments.length; i++) { + let name = null; + + if (typeof segments[i].generate !== 'function') { + let { type, value } = segments[i]; + if (type === 1) { + // dynamic + name = `:${value}`; + } else if (type === 2) { + // star + name = `*${value}`; + } else { + name = value; + } + } + + if (name) { + url.push(name); + } + } + + let fullUrl = url.join('/'); + + if (fullUrl.match(/_unused_dummy_/)) { + fullUrl = ''; + } else { + fullUrl = `/${fullUrl}`; + fullUrl = locationImplementation.formatURL(fullUrl); + } + + return fullUrl; +} + +/** + * + * @param {String} owner + * @param {String} name + * @return {Void} + */ +function routeHasBeenDefined(owner: any, name: string) { + return owner.hasRegistration(`template:${name}`) || owner.hasRegistration(`route:${name}`); +} diff --git a/packages/@ember/debug/ember-inspector-support/utils/ember-object-names.ts b/packages/@ember/debug/ember-inspector-support/utils/ember-object-names.ts new file mode 100644 index 00000000000..91cb2f6c272 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/utils/ember-object-names.ts @@ -0,0 +1,45 @@ +import MutableArray from '@ember/array/mutable'; +import Evented from '@ember/object/evented'; +import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; +import MutableEnumerable from '@ember/enumerable/mutable'; +import { NativeArray } from '@ember/array'; +import { ControllerMixin } from '@ember/controller'; +import Observable from '@ember/object/observable'; +import { ActionHandler, TargetActionSupport } from '@ember/-internals/runtime'; +import CoreObject from '@ember/object/core'; +import EmberObject from '@ember/object'; +import Component from '@ember/component'; +import { + ActionSupport, + ChildViewsSupport, + ClassNamesSupport, + CoreView, + ViewMixin, + ViewStateSupport, +} from '@ember/-internals/views'; +/** + * Add Known Ember Mixins and Classes so we can label them correctly in the inspector + */ +const emberNames = new Map([ + [Evented, 'Evented Mixin'], + [PromiseProxyMixin, 'PromiseProxy Mixin'], + [MutableArray, 'MutableArray Mixin'], + [MutableEnumerable, 'MutableEnumerable Mixin'], + [NativeArray, 'NativeArray Mixin'], + [Observable, 'Observable Mixin'], + [ControllerMixin, 'Controller Mixin'], + [ActionHandler, 'ActionHandler Mixin'], + [CoreObject, 'CoreObject'], + [EmberObject, 'EmberObject'], + [Component, 'Component'], + [TargetActionSupport, 'TargetActionSupport Mixin'], + [ViewStateSupport, 'ViewStateSupport Mixin'], + [ViewMixin, 'View Mixin'], + [ActionSupport, 'ActionSupport Mixin'], + [ClassNamesSupport, 'ClassNamesSupport Mixin'], + [ChildViewsSupport, 'ChildViewsSupport Mixin'], + [ViewStateSupport, 'ViewStateSupport Mixin'], + [CoreView, 'CoreView'], +]); + +export default emberNames; diff --git a/packages/@ember/debug/ember-inspector-support/utils/ember/object/internals.ts b/packages/@ember/debug/ember-inspector-support/utils/ember/object/internals.ts new file mode 100644 index 00000000000..59052e796c8 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/utils/ember/object/internals.ts @@ -0,0 +1,14 @@ +import { guidFor as emberGuidFor } from '@ember/-internals/utils'; + +// it can happen that different ember apps/iframes have the same id for different objects +// since the implementation is just a counter, so we add a prefix per iframe & app +let perIframePrefix = parseInt(String(Math.random() * 1000)).toString() + '-'; +let prefix = ''; +let guidFor = (obj: any, pref?: string) => + `${perIframePrefix + (pref || prefix)}-${emberGuidFor(obj)}`; + +export function setGuidPrefix(pref: string) { + prefix = pref; +} + +export { guidFor }; diff --git a/packages/@ember/debug/ember-inspector-support/utils/get-object-name.ts b/packages/@ember/debug/ember-inspector-support/utils/get-object-name.ts new file mode 100644 index 00000000000..84b27927e04 --- /dev/null +++ b/packages/@ember/debug/ember-inspector-support/utils/get-object-name.ts @@ -0,0 +1,63 @@ +import emberNames from './ember-object-names'; + +export default function getObjectName(object: any): string { + let name = ''; + let className = + (object.constructor && (emberNames.get(object.constructor) || object.constructor.name)) || ''; + + if (object instanceof Function) { + return 'Function ' + object.name; + } + + // check if object is a primitive value + if (object !== Object(object)) { + return typeof object; + } + + if (Array.isArray(object)) { + return 'array'; + } + + if (object.constructor && object.constructor.prototype === object) { + let { constructor } = object; + + if ( + constructor.toString && + constructor.toString !== Object.prototype.toString && + constructor.toString !== Function.prototype.toString + ) { + try { + name = constructor.toString(); + } catch { + name = constructor.name; + } + } else { + name = constructor.name; + } + } else if ( + 'toString' in object && + object.toString !== Object.prototype.toString && + object.toString !== Function.prototype.toString + ) { + try { + name = object.toString(); + } catch { + // + } + } + + // If the class has a decent looking name, and the `toString` is one of the + // default Ember toStrings, replace the constructor portion of the toString + // with the class name. We check the length of the class name to prevent doing + // this when the value is minified. + if ( + name.match(/<.*:.*>/) && + !className.startsWith('_') && + className.length > 2 && + className !== 'Class' + ) { + return name.replace(/<.*:/, `<${className}:`); + } + + return name || className || '(unknown class)'; +} diff --git a/packages/@ember/debug/lib/assert.ts b/packages/@ember/debug/lib/assert.ts deleted file mode 100644 index 1c1a00184b1..00000000000 --- a/packages/@ember/debug/lib/assert.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { DEBUG } from '@glimmer/env'; - -export interface AssertFunc { - (desc: string, condition: unknown): asserts condition; - (desc: string): never; -} - -export let assert: AssertFunc = (() => {}) as unknown as AssertFunc; - -export function setAssert(implementation: typeof assert): typeof assert { - assert = implementation; - return implementation; -} - -if (DEBUG) { - /** - Verify that a certain expectation is met, or throw a exception otherwise. - - This is useful for communicating assumptions in the code to other human - readers as well as catching bugs that accidentally violates these - expectations. - - Assertions are removed from production builds, so they can be freely added - for documentation and debugging purposes without worries of incuring any - performance penalty. However, because of that, they should not be used for - checks that could reasonably fail during normal usage. Furthermore, care - should be taken to avoid accidentally relying on side-effects produced from - evaluating the condition itself, since the code will not run in production. - - ```javascript - import { assert } from '@ember/debug'; - - // Test for truthiness - assert('Must pass a string', typeof str === 'string'); - - // Fail unconditionally - assert('This code path should never be run'); - ``` - - @method assert - @static - @for @ember/debug - @param {String} description Describes the expectation. This will become the - text of the Error thrown if the assertion fails. - @param {any} condition Must be truthy for the assertion to pass. If - falsy, an exception will be thrown. - @public - @since 1.0.0 - */ - function assert(desc: string): never; - function assert(desc: string, test: unknown): asserts test; - // eslint-disable-next-line no-inner-declarations - function assert(desc: string, test?: unknown): asserts test { - if (!test) { - throw new Error(`Assertion Failed: ${desc}`); - } - } - setAssert(assert); -} diff --git a/packages/@ember/debug/lib/inspect.ts b/packages/@ember/debug/lib/inspect.ts deleted file mode 100644 index 2063e300f7c..00000000000 --- a/packages/@ember/debug/lib/inspect.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { assert } from './assert'; -const { toString: objectToString } = Object.prototype; -const { toString: functionToString } = Function.prototype; -const { isArray } = Array; -const { keys: objectKeys } = Object; -const { stringify } = JSON; -const LIST_LIMIT = 100; -const DEPTH_LIMIT = 4; -const SAFE_KEY = /^[\w$]+$/; - -/** - @module @ember/debug -*/ -/** - Convenience method to inspect an object. This method will attempt to - convert the object into a useful string description. - - It is a pretty simple implementation. If you want something more robust, - use something like JSDump: https://github.com/NV/jsDump - - @method inspect - @static - @param {Object} obj The object you want to inspect. - @return {String} A description of the object - @since 1.4.0 - @private -*/ -export default function inspect(this: any, obj: any | null | undefined): string { - // detect Node util.inspect call inspect(depth: number, opts: object) - if (typeof obj === 'number' && arguments.length === 2) { - return this; - } - - return inspectValue(obj, 0); -} - -function inspectValue(value: any | null | undefined, depth: number, seen?: WeakSet) { - let valueIsArray = false; - switch (typeof value) { - case 'undefined': - return 'undefined'; - case 'object': - if (value === null) return 'null'; - if (isArray(value)) { - valueIsArray = true; - break; - } - // is toString Object.prototype.toString or undefined then traverse - if (value.toString === objectToString || value.toString === undefined) { - break; - } - // custom toString - return value.toString(); - case 'function': - return value.toString === functionToString - ? value.name - ? `[Function:${value.name}]` - : `[Function]` - : value.toString(); - case 'string': - return stringify(value); - case 'symbol': - case 'boolean': - case 'number': - default: - return value.toString(); - } - if (seen === undefined) { - seen = new WeakSet(); - } else { - if (seen.has(value)) return `[Circular]`; - } - seen.add(value); - - return valueIsArray - ? inspectArray(value, depth + 1, seen) - : inspectObject(value, depth + 1, seen); -} - -function inspectKey(key: string) { - return SAFE_KEY.test(key) ? key : stringify(key); -} - -function inspectObject(obj: T, depth: number, seen: WeakSet) { - if (depth > DEPTH_LIMIT) { - return '[Object]'; - } - let s = '{'; - let keys = objectKeys(obj) as Array; - for (let i = 0; i < keys.length; i++) { - s += i === 0 ? ' ' : ', '; - - if (i >= LIST_LIMIT) { - s += `... ${keys.length - LIST_LIMIT} more keys`; - break; - } - - let key = keys[i]; - assert('has key', key); // Looping over array - s += `${inspectKey(String(key))}: ${inspectValue(obj[key], depth, seen)}`; - } - s += ' }'; - return s; -} - -function inspectArray(arr: any[], depth: number, seen: WeakSet) { - if (depth > DEPTH_LIMIT) { - return '[Array]'; - } - let s = '['; - for (let i = 0; i < arr.length; i++) { - s += i === 0 ? ' ' : ', '; - - if (i >= LIST_LIMIT) { - s += `... ${arr.length - LIST_LIMIT} more items`; - break; - } - - s += inspectValue(arr[i], depth, seen); - } - s += ' ]'; - return s; -} diff --git a/packages/@ember/debug/lib/testing.ts b/packages/@ember/debug/lib/testing.ts deleted file mode 100644 index dcda9a02f27..00000000000 --- a/packages/@ember/debug/lib/testing.ts +++ /dev/null @@ -1,9 +0,0 @@ -let testing = false; - -export function isTesting(): boolean { - return testing; -} - -export function setTesting(value: boolean) { - testing = Boolean(value); -} diff --git a/packages/@ember/debug/lib/warn.ts b/packages/@ember/debug/lib/warn.ts deleted file mode 100644 index 0a048571270..00000000000 --- a/packages/@ember/debug/lib/warn.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { DEBUG } from '@glimmer/env'; - -import { assert } from './assert'; -import type { HandlerCallback } from './handlers'; -import { invoke, registerHandler as genericRegisterHandler } from './handlers'; - -export interface WarnOptions { - id: string; -} - -export type RegisterHandlerFunc = (handler: HandlerCallback) => void; -export interface WarnFunc { - (message: string): void; - (message: string, test: boolean): void; - (message: string, options: WarnOptions): void; - (message: string, test: boolean, options: WarnOptions): void; -} - -let registerHandler: RegisterHandlerFunc = () => {}; -let warn: WarnFunc = () => {}; -let missingOptionsDeprecation: string; -let missingOptionsIdDeprecation: string; - -/** -@module @ember/debug -*/ - -if (DEBUG) { - /** - Allows for runtime registration of handler functions that override the default warning behavior. - Warnings are invoked by calls made to [@ember/debug/warn](/ember/release/classes/@ember%2Fdebug/methods/warn?anchor=warn). - The following example demonstrates its usage by registering a handler that does nothing overriding Ember's - default warning behavior. - - ```javascript - import { registerWarnHandler } from '@ember/debug'; - - // next is not called, so no warnings get the default behavior - registerWarnHandler(() => {}); - ``` - - The handler function takes the following arguments: - -
    -
  • message - The message received from the warn call.
  • -
  • options - An object passed in with the warn call containing additional information including:
  • -
      -
    • id - An id of the warning in the form of package-name.specific-warning.
    • -
    -
  • next - A function that calls into the previously registered handler.
  • -
- - @public - @static - @method registerWarnHandler - @for @ember/debug - @param handler {Function} A function to handle warnings. - @since 2.1.0 - */ - registerHandler = function registerHandler(handler) { - genericRegisterHandler('warn', handler); - }; - - registerHandler(function logWarning(message) { - /* eslint-disable no-console */ - console.warn(`WARNING: ${message}`); - /* eslint-enable no-console */ - }); - - missingOptionsDeprecation = - 'When calling `warn` you ' + - 'must provide an `options` hash as the third parameter. ' + - '`options` should include an `id` property.'; - missingOptionsIdDeprecation = 'When calling `warn` you must provide `id` in options.'; - - /** - Display a warning with the provided message. - - * In a production build, this method is defined as an empty function (NOP). - Uses of this method in Ember itself are stripped from the ember.prod.js build. - - ```javascript - import { warn } from '@ember/debug'; - import tomsterCount from './tomster-counter'; // a module in my project - - // Log a warning if we have more than 3 tomsters - warn('Too many tomsters!', tomsterCount <= 3, { - id: 'ember-debug.too-many-tomsters' - }); - ``` - - @method warn - @for @ember/debug - @static - @param {String} message A warning to display. - @param {Boolean|Object} test An optional boolean. If falsy, the warning - will be displayed. If `test` is an object, the `test` parameter can - be used as the `options` parameter and the warning is displayed. - @param {Object} options - @param {String} options.id The `id` can be used by Ember debugging tools - to change the behavior (raise, log, or silence) for that specific warning. - The `id` should be namespaced by dots, e.g. "ember-debug.feature-flag-with-features-stripped" - @public - @since 1.0.0 - */ - warn = function warn(message: string, test?: boolean | WarnOptions, options?: WarnOptions) { - if (arguments.length === 2 && typeof test === 'object') { - options = test; - test = false; - } - - assert(missingOptionsDeprecation, Boolean(options)); - assert(missingOptionsIdDeprecation, Boolean(options && options.id)); - - // SAFETY: we have explicitly assigned `false` if the user invoked the - // arity-2 version of the overload, so we know `test` is always either - // `undefined` or a `boolean` for type-safe callers. - invoke('warn', message, test as boolean | undefined, options); - }; -} - -export default warn; -export { registerHandler, missingOptionsIdDeprecation, missingOptionsDeprecation };