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

Commit 2696e52

Browse files
authored
Merge pull request #385 from glimmerjs/component-signature
Component signature
2 parents ee9fbf0 + 2c6685a commit 2696e52

File tree

7 files changed

+179
-37
lines changed

7 files changed

+179
-37
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: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,87 @@ 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+
// Type-only "symbol" to use with `EmptyObject` below, so that it is *not*
32+
// equivalent to an empty interface.
33+
declare const Empty: unique symbol;
34+
35+
/**
36+
* This provides us a way to have a "fallback" which represents an empty object,
37+
* without the downsides of how TS treats `{}`. Specifically: this will
38+
* correctly leverage "excess property checking" so that, given a component
39+
* which has no named args, if someone invokes it with any named args, they will
40+
* get a type error.
41+
*
42+
* @internal This is exported so declaration emit works (if it were not emitted,
43+
* declarations which fall back to it would not work). It is *not* intended for
44+
* public usage, and the specific mechanics it uses may change at any time.
45+
* The location of this export *is* part of the public API, because moving it
46+
* will break existing declarations, but is not legal for end users to import
47+
* themselves, so ***DO NOT RELY ON IT***.
48+
*/
49+
export type EmptyObject = { [Empty]?: true };
50+
51+
type GetOrElse<Obj, K, Fallback> = K extends keyof Obj ? Obj[K] : Fallback;
52+
53+
/** Given a signature `S`, get back the `Args` type. */
54+
type ArgsFor<S> = 'Args' extends keyof S
55+
? S['Args'] extends { Named?: object; Positional?: unknown[] } // Are they longhand already?
56+
? {
57+
Named: GetOrElse<S['Args'], 'Named', EmptyObject>;
58+
Positional: GetOrElse<S['Args'], 'Positional', []>;
59+
}
60+
: { Named: S['Args']; Positional: [] }
61+
: { Named: EmptyObject; Positional: [] };
62+
63+
/**
64+
* Given any allowed shorthand form of a signature, desugars it to its full
65+
* expanded type.
66+
*
67+
* @internal This is only exported so we can avoid duplicating it in
68+
* [Glint](https://github.com/typed-ember/glint) or other such tooling. It is
69+
* *not* intended for public usage, and the specific mechanics it uses may
70+
* change at any time. Although the signature produced by is part of Glimmer's
71+
* public API the existence and mechanics of this specific symbol are *not*,
72+
* so ***DO NOT RELY ON IT***.
73+
*/
74+
export type ExpandSignature<T> = {
75+
Element: GetOrElse<T, 'Element', null>;
76+
Args: keyof T extends 'Args' | 'Element' | 'Blocks' // Is this a `Signature`?
77+
? ArgsFor<T> // Then use `Signature` args
78+
: { Named: T; Positional: [] }; // Otherwise fall back to classic `Args`.
79+
Blocks: 'Blocks' extends keyof T
80+
? {
81+
[Block in keyof T['Blocks']]: T['Blocks'][Block] extends unknown[]
82+
? { Positional: T['Blocks'][Block] }
83+
: T['Blocks'][Block];
84+
}
85+
: EmptyObject;
86+
};
87+
88+
/**
89+
* @internal we use this type for convenience internally; inference means users
90+
* should not normally need to name it
91+
*/
92+
export type Args<S> = ExpandSignature<S>['Args']['Named'];
93+
1994
/**
2095
* The `Component` class defines an encapsulated UI element that is rendered to
2196
* the DOM. A component is made up of a template and, optionally, this component
@@ -139,7 +214,7 @@ if (DEBUG) {
139214
* `args` property. For example, if `{{@firstName}}` is `Tom` in the template,
140215
* inside the component `this.args.firstName` would also be `Tom`.
141216
*/
142-
export default class GlimmerComponent<Args extends {} = {}> {
217+
export default class GlimmerComponent<S = unknown> {
143218
/**
144219
* Constructs a new component and assigns itself the passed properties. You
145220
* should not construct new components yourself. Instead, Glimmer will
@@ -148,7 +223,7 @@ export default class GlimmerComponent<Args extends {} = {}> {
148223
* @param owner
149224
* @param args
150225
*/
151-
constructor(_owner: unknown, args: Args) {
226+
constructor(_owner: unknown, args: Args<S>) {
152227
if (DEBUG && !ARGS_SET.has(args)) {
153228
throw new Error(
154229
`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 +260,7 @@ export default class GlimmerComponent<Args extends {} = {}> {
185260
* <p>Welcome, {{@firstName}} {{@lastName}}!</p>
186261
* ```
187262
*/
188-
args: Readonly<Args>;
263+
readonly args: Readonly<Args<S>>;
189264

190265
get isDestroying(): boolean {
191266
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 = unknown> 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 = unknown> extends _GlimmerComponent<S> {
7+
constructor(owner: object, args: Args<S>) {
88
super(owner, args);
99

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

packages/@glimmer/tracking/src/tracked.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ function descriptorForField<T extends object, K extends keyof T>(
121121
): PropertyDescriptor {
122122
if (DEBUG && desc && (desc.value || desc.get || desc.set)) {
123123
throw new Error(
124-
`You attempted to use @tracked on ${key}, but that element is not a class field. @tracked is only usable on class fields. Native getters and setters will autotrack add any tracked fields they encounter, so there is no need mark getters and setters with @tracked.`
124+
`You attempted to use @tracked on ${String(
125+
key
126+
)}, but that element is not a class field. @tracked is only usable on class fields. Native getters and setters will autotrack add any tracked fields they encounter, so there is no need mark getters and setters with @tracked.`
125127
);
126128
}
127129

test/types/component-test.ts

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

4-
const Component = gc.default;
4+
// Imported from non-public-API so we can check that we are publishing what we
5+
// expect to be -- and this keeps us honest about the fact that if we *change*
6+
// this import location, we've broken any existing declarations published using
7+
// the current type signatures.
8+
import { EmptyObject } from '@glimmer/component/addon/-private/component';
59

6-
hasExactKeys<{
7-
default: unknown;
8-
}>()(gc);
10+
const Component = gc.default;
911

10-
// $ExpectType typeof GlimmerComponent
11-
gc.default;
12+
expectTypeOf(gc).toHaveProperty('default');
13+
expectTypeOf(gc.default).toEqualTypeOf<typeof Component>();
1214

1315
type Args = {
1416
foo: number;
1517
};
1618

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

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

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

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

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

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

38-
// $ExpectError
39-
component.args.bar = 123;
97+
const componentWithFullSig = new Component<FullLongSig>({}, { foo: 123 });
98+
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)