Skip to content

Commit

Permalink
Introduce Component Signature (RFC 0748)
Browse files Browse the repository at this point in the history
Adds backwards-compatible support for component `Signature`s per [Ember
RFC #0748][rfc]. The public API of `Component` is now *more* permissive
than it was previously, because it changes the type parameter to be an
unconstrained generic `<S>` (for "signature") which can accept *either*
the existing `Args` types *or* a new `Signature` which includes `Args`
and adds `Blocks` and `Element`.

[rfc]: emberjs/rfcs#748

The `Args` part of the new signature work exactly like the old
args-only type did. The `Blocks` and `Element` parts of a signature are
inert from the perspective of TypeScript users who are not yet using
[Glint][glint], but Glint users will benefit directly once Glint
releases an update which can requires a version of `@glimmer/component`
incorporating this change.

[glint]: https://github.com/typed-ember/glint

Since this change is backwards compatible, we can deprecate the
non-`Signature` form at a later time when we are ready for a 2.0
release.

To validate these changes, with the relatively complicated type
machinery they require under the hood, this also introduces the
`expect-type` type testing library, rewrites the existing type tests
using it, and introduces new type tests for all supported forms of the
`Signature`.

(cherry picked from commit 967d028)
  • Loading branch information
chriskrycho committed Mar 29, 2022
1 parent 8db4a62 commit 95480cb
Show file tree
Hide file tree
Showing 7 changed files with 585 additions and 93 deletions.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"test": "ember test && ember test --env production",
"test:node": "bin/run-node-tests.js",
"test:ember": "yarn workspace @glimmer/component ember try:one",
"test:types": "dtslint test/types",
"test:types": "tsc --noEmit --project test/types && dtslint test/types",
"test:watch": "testem",
"tslint": "tslint --project tsconfig.json",
"problems": "tsc -p tsconfig.json --noEmit"
},
Expand Down Expand Up @@ -39,6 +40,7 @@
"ember-cli": "^3.7.1",
"ember-cli-sauce": "^1.3.0",
"eslint": "^4.3.0",
"expect-type": "^0.13.0",
"glob": "^7.0.5",
"lerna-changelog": "^0.7.0",
"loader.js": "^4.0.10",
Expand Down Expand Up @@ -77,4 +79,4 @@
"deprecation": ":warning: Deprecation"
}
}
}
}
95 changes: 84 additions & 11 deletions packages/@glimmer/component/addon/-private/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,87 @@ import { DEBUG } from '@glimmer/env';
import { setOwner } from './owner';
import { isDestroying, isDestroyed } from './destroyables';

export let ARGS_SET: WeakMap<any, boolean>;
// This provides a type-safe `WeakMap`: the getter and setter link the key to a
// specific value. This is how `WeakMap`s actually behave, but the TS type
// system does not (yet!) have a good way to capture that for types like
// `WeakMap` where the type is generic over another generic type (here, `Args`).
interface ArgsSetMap extends WeakMap<Args<unknown>, boolean> {
get<S>(key: Args<S>): boolean | undefined;
set<S>(key: Args<S>, value: boolean): this;
}

// SAFETY: this only holds because we *only* acces this when `DEBUG` is `true`.
// There is not a great way to connect that data in TS at present.
export let ARGS_SET: ArgsSetMap;

if (DEBUG) {
ARGS_SET = new WeakMap();
ARGS_SET = new WeakMap() as ArgsSetMap;
}

// --- Type utilities for component signatures --- //
// Type-only "symbol" to use with `EmptyObject` below, so that it is *not*
// equivalent to an empty interface.
declare const Empty: unique symbol;

/**
* This provides us a way to have a "fallback" which represents an empty object,
* without the downsides of how TS treats `{}`. Specifically: this will
* correctly leverage "excess property checking" so that, given a component
* which has no named args, if someone invokes it with any named args, they will
* get a type error.
*
* @internal This is exported so declaration emit works (if it were not emitted,
* declarations which fall back to it would not work). It is *not* intended for
* public usage, and the specific mechanics it uses may change at any time.
* The location of this export *is* part of the public API, because moving it
* will break existing declarations, but is not legal for end users to import
* themselves, so ***DO NOT RELY ON IT***.
*/
export type EmptyObject = { [Empty]?: true };

type GetOrElse<Obj, K, Fallback> = K extends keyof Obj ? Obj[K] : Fallback;

/** Given a signature `S`, get back the `Args` type. */
type ArgsFor<S> = 'Args' extends keyof S
? S['Args'] extends { Named?: object; Positional?: unknown[] } // Are they longhand already?
? {
Named: GetOrElse<S['Args'], 'Named', EmptyObject>;
Positional: GetOrElse<S['Args'], 'Positional', []>;
}
: { Named: S['Args']; Positional: [] }
: { Named: EmptyObject; Positional: [] };

/**
* Given any allowed shorthand form of a signature, desugars it to its full
* expanded type.
*
* @internal This is only exported so we can avoid duplicating it in
* [Glint](https://github.com/typed-ember/glint) or other such tooling. It is
* *not* intended for public usage, and the specific mechanics it uses may
* change at any time. Although the signature produced by is part of Glimmer's
* public API the existence and mechanics of this specific symbol are *not*,
* so ***DO NOT RELY ON IT***.
*/
export type ExpandSignature<T> = {
Element: GetOrElse<T, 'Element', null>;
Args: keyof T extends 'Args' | 'Element' | 'Blocks' // Is this a `Signature`?
? ArgsFor<T> // Then use `Signature` args
: { Named: T; Positional: [] }; // Otherwise fall back to classic `Args`.
Blocks: 'Blocks' extends keyof T
? {
[Block in keyof T['Blocks']]: T['Blocks'][Block] extends unknown[]
? { Positional: T['Blocks'][Block] }
: T['Blocks'][Block];
}
: EmptyObject;
};

/**
* @internal we use this type for convenience internally; inference means users
* should not normally need to name it
*/
export type Args<S> = ExpandSignature<S>['Args']['Named'];

/**
* The `Component` class defines an encapsulated UI element that is rendered to
* the DOM. A component is made up of a template and, optionally, this component
Expand Down Expand Up @@ -131,7 +206,7 @@ if (DEBUG) {
* `args` property. For example, if `{{@firstName}}` is `Tom` in the template,
* inside the component `this.args.firstName` would also be `Tom`.
*/
export default class BaseComponent<T = object> {
export default class BaseComponent<S = unknown> {
/**
* Constructs a new component and assigns itself the passed properties. You
* should not construct new components yourself. Instead, Glimmer will
Expand All @@ -140,12 +215,10 @@ export default class BaseComponent<T = object> {
* @param owner
* @param args
*/
constructor(owner: unknown, args: T) {
constructor(owner: unknown, args: Args<S>) {
if (DEBUG && !(owner !== null && typeof owner === 'object' && ARGS_SET.has(args))) {
throw new Error(
`You must pass both the owner and args to super() in your component: ${
this.constructor.name
}. You can pass them directly, or use ...arguments to pass all arguments through.`
`You must pass both the owner and args to super() in your component: ${this.constructor.name}. You can pass them directly, or use ...arguments to pass all arguments through.`
);
}

Expand Down Expand Up @@ -177,18 +250,18 @@ export default class BaseComponent<T = object> {
* <p>Welcome, {{@firstName}} {{@lastName}}!</p>
* ```
*/
args: Readonly<T>;
args: Readonly<Args<S>>;

get isDestroying() {
get isDestroying(): boolean {
return isDestroying(this);
}

get isDestroyed() {
get isDestroyed(): boolean {
return isDestroyed(this);
}

/**
* Called before the component has been removed from the DOM.
*/
willDestroy() {}
willDestroy(): void {}
}
39 changes: 31 additions & 8 deletions packages/@glimmer/component/addon/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { DEBUG } from '@glimmer/env';
import ApplicationInstance from '@ember/application/instance';
import type ApplicationInstance from '@ember/application/instance';
import { setComponentManager } from '@ember/component';
import { gte } from 'ember-compatibility-helpers';

// Hax because the old version of `@types/ember__component` the `1.x` branch
// uses does not provide any types for `setComponentManager` *and* because we
// are using a very old version of `setComponentManager` for versions before
// Ember 3.8.
declare module '@ember/component' {
// The modern version.
export function setComponentManager<T extends object>(
factory: (owner: ApplicationInstance) => GlimmerComponentManager,
componentClass: T
): T;

// The pre-3.8 version.
export function setComponentManager<T extends object>(name: string, componentClass: T): T;
}

import GlimmerComponentManager from './-private/ember-component-manager';
import GlimmerComponentBase from './-private/component';
import GlimmerComponentBase, { Args } from './-private/component';

let GlimmerComponent = GlimmerComponentBase;

Expand All @@ -13,15 +28,19 @@ if (DEBUG) {

// TODO: Add GlimmerComponent API docs link to these messages once API docs are live
function throwMethodUseError(methodName: string) {
throw new Error(`You attempted to define the '${methodName}' method on a Glimmer Component, but that lifecycle hook does not exist in Ember.js applications, it only exists in Glimmer.js apps. You can rename this method, and you can trigger it using a modifier such as {{did-insert}} from '@ember/render-modifiers': https://github.com/emberjs/ember-render-modifiers.`);
throw new Error(
`You attempted to define the '${methodName}' method on a Glimmer Component, but that lifecycle hook does not exist in Ember.js applications, it only exists in Glimmer.js apps. You can rename this method, and you can trigger it using a modifier such as {{did-insert}} from '@ember/render-modifiers': https://github.com/emberjs/ember-render-modifiers.`
);
}

function throwPropertyUseError(propertyName: string) {
throw new Error(`You attempted to access the '${propertyName}' property on a Glimmer Component, but that property does not exist in Ember.js applications, it only exists in Glimmer.js apps. You define a class field with the same name on your component class and it will overwrite this error message, but it will not be used by the framework.`);
throw new Error(
`You attempted to access the '${propertyName}' property on a Glimmer Component, but that property does not exist in Ember.js applications, it only exists in Glimmer.js apps. You define a class field with the same name on your component class and it will overwrite this error message, but it will not be used by the framework.`
);
}

GlimmerComponent = class GlimmerDebugComponent<T> extends GlimmerComponent<T> {
constructor(owner: unknown, args: T) {
GlimmerComponent = class GlimmerDebugComponent<S> extends GlimmerComponent<S> {
constructor(owner: unknown, args: Args<S>) {
super(owner, args);

if (typeof this['didInsertElement'] === 'function') {
Expand All @@ -34,9 +53,13 @@ if (DEBUG) {
}
};

let proto = GlimmerComponent.prototype as any;
let proto = GlimmerComponent.prototype;

function defineErrorProp(proto, key, getterMethod) {
function defineErrorProp(
proto: GlimmerComponentBase,
key: string,
getterMethod: (key: string) => unknown
) {
Object.defineProperty(proto, key, {
get: () => getterMethod(key),
set(value) {
Expand Down
4 changes: 2 additions & 2 deletions packages/@glimmer/component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"ember-cli-string-utils": "^1.1.0",
"ember-cli-typescript": "3.0.0",
"ember-cli-version-checker": "^3.1.3",
"ember-compatibility-helpers": "^1.1.2"
"ember-compatibility-helpers": "^1.2.6"
},
"devDependencies": {
"@ember/optional-features": "^0.6.1",
Expand Down Expand Up @@ -93,4 +93,4 @@
"defaultBlueprint": "install-glimmer-component",
"main": "ember-addon-main.js"
}
}
}
6 changes: 3 additions & 3 deletions packages/@glimmer/component/src/component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { assert } from '@glimmer/util';
import { setComponentManager } from '@glimmer/application';
import BaseComponent from '../addon/-private/component';
import BaseComponent, { Args } from '../addon/-private/component';
import GlimmerComponentManager from './component-manager';

export interface Bounds {
firstNode: Node;
lastNode: Node;
}

export default class Component<Args extends {} = {}> extends BaseComponent<Args> {
args: Args;
export default class Component<S> extends BaseComponent<S> {
args: Readonly<Args<S>>;

/**
* Development-mode only name of the component, useful for debugging.
Expand Down
Loading

0 comments on commit 95480cb

Please sign in to comment.