Skip to content
This repository was archived by the owner on Oct 29, 2024. It is now read-only.

Commit 967d028

Browse files
committed
Introduce Component Signature (RFC 0748)
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`.
1 parent ee9fbf0 commit 967d028

File tree

6 files changed

+153
-36
lines changed

6 files changed

+153
-36
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"test:babel-plugins": "yarn workspace @glimmer/babel-preset test",
2323
"test:browsers": "testem ci",
2424
"test:ember": "yarn workspace @glimmer/component ember try:one",
25-
"test:types": "dtslint test/types",
25+
"test:types": "tsc --noEmit --project test/types && dtslint test/types",
2626
"test:watch": "testem"
2727
},
2828
"browserslist": {
@@ -53,6 +53,7 @@
5353
"@typescript-eslint/parser": "^4.18.0",
5454
"babel-loader": "^8.1.0",
5555
"dtslint": "^3.4.1",
56+
"expect-type": "~0.13.0",
5657
"eslint": "^6.8.0",
5758
"eslint-config-prettier": "^6.10.1",
5859
"eslint-plugin-prettier": "^3.1.2",

packages/@glimmer/component/addon/-private/component.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,66 @@ export function setDestroyed(component: GlimmerComponent<object>): void {
1010
DESTROYED.set(component, true);
1111
}
1212

13-
export let ARGS_SET: WeakMap<object, boolean>;
13+
// This provides a type-safe `WeakMap`: the getter and setter link the key to a
14+
// specific value. This is how `WeakMap`s actually behave, but the TS type
15+
// system does not (yet!) have a good way to capture that for types like
16+
// `WeakMap` where the type is generic over another generic type (here, `Args`).
17+
interface ArgsSetMap extends WeakMap<Args<unknown>, boolean> {
18+
get<S>(key: Args<S>): boolean | undefined;
19+
set<S>(key: Args<S>, value: boolean): this;
20+
}
21+
22+
// SAFETY: this only holds because we *only* acces this when `DEBUG` is `true`.
23+
// There is not a great way to connect that data in TS at present.
24+
export let ARGS_SET: ArgsSetMap;
1425

1526
if (DEBUG) {
16-
ARGS_SET = new WeakMap();
27+
ARGS_SET = new WeakMap() as ArgsSetMap;
1728
}
1829

30+
// --- Type utilities for component signatures --- //
31+
32+
// This provides us a way to have a "fallback" which represents an empty object,
33+
// without the downsides of how TS treats `{}`. Specifically: this will
34+
// correctly leverage "excess property checking" so that, given a component
35+
// which has no named args, if someone invokes it with any named args, they will
36+
// get a type error.
37+
declare const Empty: unique symbol;
38+
type EmptyObject = { [Empty]?: true };
39+
40+
type GetOrElse<Obj, K, Fallback> = K extends keyof Obj ? Obj[K] : Fallback;
41+
42+
/** Given a signature `S`, get back the `Args` type. */
43+
type ArgsFor<S> = 'Args' extends keyof S
44+
? S['Args'] extends { Named?: object; Positional?: unknown[] } // Are they longhand already?
45+
? {
46+
Named: GetOrElse<S['Args'], 'Named', EmptyObject>;
47+
Positional: GetOrElse<S['Args'], 'Positional', []>;
48+
}
49+
: { Named: S['Args']; Positional: [] }
50+
: { Named: EmptyObject; Positional: [] };
51+
52+
/** Given any allowed shorthand form of a signature, desugars it to its full expanded type */
53+
type ExpandSignature<T> = {
54+
Element: GetOrElse<T, 'Element', null>;
55+
Args: keyof T extends 'Args' | 'Element' | 'Blocks' // Is this a `Signature`?
56+
? ArgsFor<T> // Then use `Signature` args
57+
: { Named: T; Positional: [] }; // Otherwise fall back to classic `Args`.
58+
Blocks: 'Blocks' extends keyof T
59+
? {
60+
[Block in keyof T['Blocks']]: T['Blocks'][Block] extends unknown[]
61+
? { Positional: T['Blocks'][Block] }
62+
: T['Blocks'][Block];
63+
}
64+
: EmptyObject;
65+
};
66+
67+
/**
68+
* @internal we use this type for convenience internally; inference means users
69+
* should not normally need to name it
70+
*/
71+
export type Args<S> = ExpandSignature<S>['Args']['Named'];
72+
1973
/**
2074
* The `Component` class defines an encapsulated UI element that is rendered to
2175
* the DOM. A component is made up of a template and, optionally, this component
@@ -139,7 +193,7 @@ if (DEBUG) {
139193
* `args` property. For example, if `{{@firstName}}` is `Tom` in the template,
140194
* inside the component `this.args.firstName` would also be `Tom`.
141195
*/
142-
export default class GlimmerComponent<Args extends {} = {}> {
196+
export default class GlimmerComponent<S = unknown> {
143197
/**
144198
* Constructs a new component and assigns itself the passed properties. You
145199
* should not construct new components yourself. Instead, Glimmer will
@@ -148,7 +202,7 @@ export default class GlimmerComponent<Args extends {} = {}> {
148202
* @param owner
149203
* @param args
150204
*/
151-
constructor(_owner: unknown, args: Args) {
205+
constructor(_owner: unknown, args: Args<S>) {
152206
if (DEBUG && !ARGS_SET.has(args)) {
153207
throw new Error(
154208
`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.`
@@ -185,7 +239,7 @@ export default class GlimmerComponent<Args extends {} = {}> {
185239
* <p>Welcome, {{@firstName}} {{@lastName}}!</p>
186240
* ```
187241
*/
188-
args: Readonly<Args>;
242+
readonly args: Readonly<Args<S>>;
189243

190244
get isDestroying(): boolean {
191245
return DESTROYING.get(this) || false;

packages/@glimmer/component/addon/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ declare module '@ember/component' {
1212
import { setComponentManager } from '@ember/component';
1313

1414
import GlimmerComponentManager from './-private/ember-component-manager';
15-
import _GlimmerComponent from './-private/component';
15+
import _GlimmerComponent, { Args } from './-private/component';
1616
import { setOwner } from '@ember/application';
1717

18-
export default class GlimmerComponent extends _GlimmerComponent {
19-
constructor(owner, args) {
18+
export default class GlimmerComponent<S> extends _GlimmerComponent<S> {
19+
constructor(owner: object, args: Args<S>) {
2020
super(owner, args);
2121

2222
if (DEBUG && !(owner !== null && typeof owner === 'object')) {

packages/@glimmer/component/src/component.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { setComponentManager, setOwner } from '@glimmer/core';
22
import GlimmerComponentManager from './component-manager';
3-
import _GlimmerComponent from '../addon/-private/component';
3+
import _GlimmerComponent, { Args } from '../addon/-private/component';
44
import { DEBUG } from '@glimmer/env';
55

6-
export default class GlimmerComponent<Args extends {} = {}> extends _GlimmerComponent<Args> {
7-
constructor(owner: object, args: Args) {
6+
export default class GlimmerComponent<S> extends _GlimmerComponent<S> {
7+
constructor(owner: object, args: Args<S>) {
88
super(owner, args);
99

1010
if (DEBUG && !(owner !== null && typeof owner === 'object')) {

test/types/component-test.ts

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,96 @@
1+
import { expectTypeOf } from 'expect-type';
12
import * as gc from '@glimmer/component';
2-
import { hasExactKeys } from './utils';
33

44
const Component = gc.default;
55

6-
hasExactKeys<{
7-
default: unknown;
8-
}>()(gc);
9-
10-
// $ExpectType typeof GlimmerComponent
11-
gc.default;
6+
expectTypeOf(gc).toHaveProperty('default');
7+
expectTypeOf(gc.default).toEqualTypeOf<typeof Component>();
128

139
type Args = {
1410
foo: number;
1511
};
1612

17-
const component = new Component<Args>({}, { foo: 123 });
13+
const componentWithLegacyArgs = new Component<Args>({}, { foo: 123 });
14+
expectTypeOf(componentWithLegacyArgs).toHaveProperty('args');
15+
expectTypeOf(componentWithLegacyArgs).toHaveProperty('isDestroying');
16+
expectTypeOf(componentWithLegacyArgs).toHaveProperty('isDestroyed');
17+
expectTypeOf(componentWithLegacyArgs).toHaveProperty('willDestroy');
18+
expectTypeOf(componentWithLegacyArgs.args).toEqualTypeOf<Readonly<Args>>();
19+
expectTypeOf(componentWithLegacyArgs.isDestroying).toEqualTypeOf<boolean>();
20+
expectTypeOf(componentWithLegacyArgs.isDestroyed).toEqualTypeOf<boolean>();
21+
expectTypeOf(componentWithLegacyArgs.willDestroy).toEqualTypeOf<() => void>();
22+
23+
interface ArgsOnly {
24+
Args: Args;
25+
}
26+
27+
const componentWithArgsOnly = new Component<ArgsOnly>({}, { foo: 123 });
28+
expectTypeOf(componentWithArgsOnly.args).toEqualTypeOf<Readonly<Args>>();
29+
30+
interface ElementOnly {
31+
Element: HTMLParagraphElement;
32+
}
33+
34+
const componentWithElOnly = new Component<ElementOnly>({}, {});
35+
36+
// We cannot check on toEqualTypeOf here b/c EmptyObject is intentionally not
37+
// public.
38+
expectTypeOf(componentWithElOnly.args).toMatchTypeOf<Readonly<{}>>();
39+
40+
interface Blocks {
41+
default: [name: string];
42+
inverse: [];
43+
}
44+
45+
interface BlockOnlySig {
46+
Blocks: Blocks;
47+
}
48+
49+
const componentWithBlockOnly = new Component<BlockOnlySig>({}, {});
50+
51+
// We cannot check on toEqualTypeOf here b/c EmptyObject is intentionally not
52+
// public.
53+
expectTypeOf(componentWithBlockOnly.args).toMatchTypeOf<Readonly<{}>>();
54+
55+
interface ArgsAndBlocks {
56+
Args: Args;
57+
Blocks: Blocks;
58+
}
59+
60+
const componentwithArgsAndBlocks = new Component<ArgsAndBlocks>({}, { foo: 123 });
61+
expectTypeOf(componentwithArgsAndBlocks.args).toEqualTypeOf<Readonly<Args>>();
1862

19-
hasExactKeys<{
20-
args: unknown;
21-
isDestroying: unknown;
22-
isDestroyed: unknown;
23-
willDestroy: unknown;
24-
}>()(component);
63+
interface ArgsAndEl {
64+
Args: Args;
65+
Element: HTMLParagraphElement;
66+
}
2567

26-
// $ExpectType Readonly<Args>
27-
component.args;
68+
const componentwithArgsAndEl = new Component<ArgsAndEl>({}, { foo: 123 });
69+
expectTypeOf(componentwithArgsAndEl.args).toEqualTypeOf<Readonly<Args>>();
2870

29-
// $ExpectType boolean
30-
component.isDestroying;
71+
interface FullShortSig {
72+
Args: Args;
73+
Element: HTMLParagraphElement;
74+
Blocks: Blocks;
75+
}
3176

32-
// $ExpectType boolean
33-
component.isDestroyed;
77+
const componentWithFullShortSig = new Component<FullShortSig>({}, { foo: 123 });
78+
expectTypeOf(componentWithFullShortSig.args).toEqualTypeOf<Readonly<Args>>();
3479

35-
// $ExpectType () => void
36-
component.willDestroy;
80+
interface FullLongSig {
81+
Args: {
82+
Named: Args;
83+
Positional: [];
84+
};
85+
Element: HTMLParagraphElement;
86+
Blocks: {
87+
default: {
88+
Params: {
89+
Positional: [name: string];
90+
};
91+
};
92+
};
93+
}
3794

38-
// $ExpectError
39-
component.args.bar = 123;
95+
const componentWithFullSig = new Component<FullLongSig>({}, { foo: 123 });
96+
expectTypeOf(componentWithFullSig.args).toEqualTypeOf<Readonly<Args>>();

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6898,6 +6898,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
68986898
dependencies:
68996899
homedir-polyfill "^1.0.1"
69006900

6901+
expect-type@~0.13.0:
6902+
version "0.13.0"
6903+
resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-0.13.0.tgz#916646a7a73f3ee77039a634ee9035efe1876eb2"
6904+
integrity sha512-CclevazQfrqo8EvbLPmP7osnb1SZXkw47XPPvUUpeMz4HuGzDltE7CaIt3RLyT9UQrwVK/LDn+KVcC0hcgjgDg==
6905+
69016906
express@^4.10.7, express@^4.17.1:
69026907
version "4.17.2"
69036908
resolved "https://registry.npmjs.org/express/-/express-4.17.2.tgz#c18369f265297319beed4e5558753cc8c1364cb3"

0 commit comments

Comments
 (0)