Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add typecsript sample rollup config + exploration of different plugins' side-effects #1099

Conversation

NullVoxPopuli
Copy link
Collaborator

@NullVoxPopuli NullVoxPopuli commented Feb 1, 2022

With rollup-plugin-ts

Resolves

TODO

  • Figure out how to avoid specifying input at all

  • Figure out why setComponentTemplate appears to be invoked twice per template

    sample output from:

    https://github.com/NullVoxPopuli/ember-addon-v2-typescript-demo

     cat dist/components/demo/index.js
    import { setComponentTemplate } from '@ember/component';
    import { hbs } from 'ember-cli-htmlbars';
    import { __decorate } from 'tslib';
    import Component from '@glimmer/component';
    import { tracked } from '@glimmer/tracking';
    
    var TEMPLATE = setComponentTemplate(TEMPLATE, hbs`Hello there!
    
    <out>{{this.active}}</out>
    
    <button {{on 'click' this.flip}}>
      flip
    </button>
    `);
    
    class Demo extends Component {
      constructor(...args) {
        super(...args);
        this.active = false;
    
        this.flip = () => this.active = !this.active;
      }
    
    }
    
    __decorate([tracked], Demo.prototype, "active", void 0);
    
    setComponentTemplate(TEMPLATE, Demo);
    
    export { Demo as default };
    //# sourceMappingURL=index.js.map

    Potential solve:
    Solve ts related double setComponentTemplate #1134

  • Ensure that app-js in package.json is updated

  • Ensure that template-only components can be imported

  • Ensure that at least test-support is its own file

  • Answer the questions

    • do we shipped compiled templates are no?
      • in most of the v2 addons out there that have components, they are still requiring the app to do the hbs to opcode conversion
        • discussion: Creating v2 addon guide #1107 (comment)
          tldr: the compiled format is tightly coupled to exact ember version, so it's unsafe to ship compiled templates.
          it may make sense if you can guarantee that addons and apps will always have the same exact ember version
          (maybe if you're doing a monorepo at work or something where everything is private)
    • how should folks specify that they want sourcemaps?

@NullVoxPopuli NullVoxPopuli changed the title Add typecsript sample rollup config Add typecsript sample rollup config + exploration of different plugins' side-effects Feb 3, 2022
@SergeAstapov
Copy link
Collaborator

@NullVoxPopuli if you could cover in some example, probably still worth having an example of shipping compiled templates.

We have a monorepo with 70+ addons and having hbs pre-compiled may yield noticeable perf improvement (even some 30s on scale matters)

@NullVoxPopuli
Copy link
Collaborator Author

We have a monorepo with 70+ addons and having hbs pre-compiled may yield noticeable perf improvement (even some 30s on scale matters)

absolutely! I kinda want to try this exact same thing at work (with similar scale, too!)

the tldr is this:

in your babel config, add:

const { precompile } = require('@glimmer/compiler'); // hope this is the same version used by your copy of ember-source 

const { resolve } = require;

module.exports = {
  plugins: [
    // other plugins
    // the important bit for compiling away the templates
    [
      resolve('babel-plugin-ember-template-compilation'),
      {
        precompile,
        enableLegacyModules: ['ember-cli-htmlbars'],
      },
    ],
  ],
}

@NullVoxPopuli
Copy link
Collaborator Author

NullVoxPopuli commented Feb 24, 2022

I just learned about declarationMap today.
brought up a question:

  • should our default configs, by default, demonstrate how to specify sourcemaps: true and declarationMap: true?

Example ts config I'm using: https://github.com/NullVoxPopuli/ember-resources/blob/main/ember-resources/rollup.config.js#L40

@ef4
Copy link
Contributor

ef4 commented Feb 24, 2022

I think using declarationMap implies that we would ship the original TS files within the published NPM packages?

If so, I think that's likely to cause trouble, because we don't want apps to start resolving the unbuilt TS files and trying to compile them, because if the typescript compiler version doesn't match perfectly they are likely to get type errors from down inside the addon. Shipping the .js, .d.ts, and .js.map is cleaner because apps can't accidentally try to compile your .ts.

@NullVoxPopuli
Copy link
Collaborator Author

I think using declarationMap implies that we would ship the original TS files within the published NPM packages?

I tested this in my big work monorepo (not using ember-resources, but in another package in the monorepo)

and without declarationMap: true:

  • doing go-to-definition in (neo)vim and webstorm took me directly to the declaration file instead of the source code
  • I had an occurrence of vscode going directly to where the function was defined in the v2 addon

and with declarationMap: true,

  • doing go-to-definition in (neo)vim took me not to the function's source, but to the entrypoint file where it is exported -- which is an improvement to the type-declarations, because you can go-to-definition again to get to the function implementation.

I tried this yarn linkd to ember-resources as well, and the behavior is the same (going to the dts file instead the source (when available)

I don't think declarationMap is helpful when the source isn't available (so.. only when in monorepo or linked)

Shipping the .js, .d.ts, and .js.map is cleaner because apps can't accidentally try to compile your .ts.

100% agree. I added an additional type of test for my output files to help keep me honest as I change rollup configs.
https://github.com/NullVoxPopuli/ember-resources/tree/main/testing/build

atm, my output is:

index.d.ts
index.d.ts.map
index.js
index.js.map

@simonihmig
Copy link
Collaborator

I tried to migrate ember-stargate (a v2 JavaScript addon) to TS (WIP PR: simonihmig/ember-stargate#491). It failed miserably, and today I think finally found the root cause...

I am basically using a similar setup like proposed here, using rollup-plugin-ts (which worked fine for another v2 addon). What is failing here though is that an injected service is always undefined. And the root cause seems to be how decorators are transpiled, especially @inject. (btw, the other addons mentioned here don't seem to use it.)

So the way rollup-plugin-ts works is to compile TS first, then Babel, AFAIU. And TS will always compile decorators away, there is no way around that (see e.g. microsoft/TypeScript#16882 and @NullVoxPopuli's own wessberg/rollup-plugin-ts#174). So what Babel then sees is regular JavaScript code without any decorators, but a class property for the injected service. With a babel config that only includes the minimally required @embroider/addon-dev/template-colocation-plugin and no other transforms, this component - when running the rollup build - gets compiled to this:

Compilation output
import { setComponentTemplate } from '@ember/component';
import { hbs } from 'ember-cli-htmlbars';
import { _ as __decorate } from '../tslib.es6-4cf1a877.js';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject } from '@ember/service';
import { assert } from '@ember/debug';

var TEMPLATE = hbs("<div\n  {{did-insert this.register}}\n  {{will-destroy this.unregister}}\n  ...attributes\n>\n  {{yield this.count}}\n</div>");

class PortalTargetComponent extends Component {
  portalService;

  get count() {
    return this.portalService.getPortalCount(this.args.name);
  }

  register(element) {
    assert('PortalTargets needs a name', this.args.name);
    const options = {
      multiple: this.args.multiple,
      onChange: this.args.onChange
    };
    this.portalService.registerTarget(this.args.name, element, options);
  }

  unregister() {
    this.portalService.unregisterTarget(this.args.name);
  }

}

__decorate([inject('-portal')], PortalTargetComponent.prototype, "portalService", void 0);

__decorate([action], PortalTargetComponent.prototype, "register", null);

__decorate([action], PortalTargetComponent.prototype, "unregister", null);

setComponentTemplate(TEMPLATE, PortalTargetComponent);

export { PortalTargetComponent as default };

You can see there is still the portalService class property. But in the compiled app, this is also compiled away (I believe ember-auto-import does this for a non-Embroider app?). Same happens when you have @babel/plugin-proposal-class-properties in the addon's babel config. You then end up with this:

Compilation output
import { _ as _defineProperty } from '../defineProperty-1e8516b1.js';
import { setComponentTemplate } from '@ember/component';
import { hbs } from 'ember-cli-htmlbars';
import { _ as __decorate } from '../tslib.es6-4cf1a877.js';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject } from '@ember/service';
import { assert } from '@ember/debug';

var TEMPLATE = hbs("<div\n  {{did-insert this.register}}\n  {{will-destroy this.unregister}}\n  ...attributes\n>\n  {{yield this.count}}\n</div>");

class PortalTargetComponent extends Component {
  constructor(...args) {
    super(...args);

    _defineProperty(this, "portalService", void 0);
  }

  get count() {
    return this.portalService.getPortalCount(this.args.name);
  }

  register(element) {
    assert('PortalTargets needs a name', this.args.name);
    const options = {
      multiple: this.args.multiple,
      onChange: this.args.onChange
    };
    this.portalService.registerTarget(this.args.name, element, options);
  }

  unregister() {
    this.portalService.unregisterTarget(this.args.name);
  }

}

__decorate([inject('-portal')], PortalTargetComponent.prototype, "portalService", void 0);

__decorate([action], PortalTargetComponent.prototype, "register", null);

__decorate([action], PortalTargetComponent.prototype, "unregister", null);

setComponentTemplate(TEMPLATE, PortalTargetComponent);

export { PortalTargetComponent as default };

As you can see, we now have the class property removed in favor of defining it in the constructor, setting it effectively to undefined.

And this exactly causes the problem! this.portalService is now undefined, although the decorator was applied correctly.
I can see that when debugging the point where the error is thrown: portalService on the component instance is undefined. However the portalService getter (added by the decorator) defined on the prototype returns the service instance just fine. So the issue is that this getter is shadowed by the constructor setting the property to undefined.

So basically the two step compilation of TS and then Babel is failing here AFAICT.

An alternative would be reverting the approach to again only use the babel rollup plugin directly (with @babel/plugin-transform-typescript), but then we would need to run tsc additionally somehow to emit the declaration files. Basically what ember-cli-typescript is doing today.

Any other ideas?

@simonihmig
Copy link
Collaborator

BTW, @NullVoxPopuli I don't understand why you have these two Babel plugins, as Babel would not see any TypeScript or decorators code when running as part of rollup-plugin-ts, as mentioned above. Or do I miss something?

@SergeAstapov
Copy link
Collaborator

@simonihmig oh, I've been there with ember-animated. What I end up doing - removed @babel/plugin-proposal-class-properties from babel.config.json:

https://github.com/ember-animation/ember-animated/blob/master/addon/babel.config.json

TBH, I'm not sure we actually need to transpile away class properties at addon build time and I think app can do it based on targets

@simonihmig
Copy link
Collaborator

Oh wow, that feedback was super fast, and more importantly it indeed fixed my issue! 🎉 Thanks a lot @SergeAstapov! 🙏

So for the record, the output is now like this:

Compilation output
import { setComponentTemplate } from '@ember/component';
import { hbs } from 'ember-cli-htmlbars';
import { _ as __decorate } from '../tslib.es6-4cf1a877.js';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject } from '@ember/service';
import { assert } from '@ember/debug';

var TEMPLATE = hbs("<div\n  {{did-insert this.register}}\n  {{will-destroy this.unregister}}\n  ...attributes\n>\n  {{yield this.count}}\n</div>");

class PortalTargetComponent extends Component {
  get count() {
    return this.portalService.getPortalCount(this.args.name);
  }

  register(element) {
    assert('PortalTargets needs a name', this.args.name);
    const options = {
      multiple: this.args.multiple,
      onChange: this.args.onChange
    };
    this.portalService.registerTarget(this.args.name, element, options);
  }

  unregister() {
    this.portalService.unregisterTarget(this.args.name);
  }

}

__decorate([inject('-portal')], PortalTargetComponent.prototype, "portalService", void 0);

__decorate([action], PortalTargetComponent.prototype, "register", null);

__decorate([action], PortalTargetComponent.prototype, "unregister", null);

setComponentTemplate(TEMPLATE, PortalTargetComponent);

export { PortalTargetComponent as default };

Which is almost the same, except for the removed class property! But this eliminates the problem I described above, that this gets transpiled further down the road in the app to assigning undefined in the constructor.

This still leaves me a bit puzzled:

  • It seems only adding @babel/preset-typescript is enough. @babel/plugin-proposal-decorators was not needed for me. Decorators are still transpiled, but again by TS, not Babel
  • Still I don't fully understand why we would need that plugin, given as mentioned before TypeScript types will already be compiled away when Babel kicks in, AFAIU
  • That the (empty) class property is removed by @babel/preset-typescript feels more like an unexpected side effect, again given that the TypeScript compilation happened before Babel, doesn't it? 🤔
  • I did try adding @babel/preset-typescript before without success. But maybe I have just messed something up in the config setup before...

At least the PR is green now! 😍

@simonihmig
Copy link
Collaborator

TBH, I'm not sure we actually need to transpile away class properties at addon build time and I think app can do it based on targets

Yeah, class props on its own are probably ok. But in my case it seems it was a combination of an (untranspiled) class property plus the transpiled decorator code (for that same property) that blew things up...

@NullVoxPopuli
Copy link
Collaborator Author

I've been having issues with converting ember-headlessui
so, I (re)investigated doing transpilation "the hard way" here: https://github.com/NullVoxPopuli/ember-headlessui/pull/1/files (if any one is curious what the differences / considerations are)
(Un)fortunately, my issues with v2 addon conversion don't appear to have to do with how the addon is transpiled.

@NullVoxPopuli
Copy link
Collaborator Author

, I'm not sure we actually need to transpile away class properties at addon build time and I think app can do it based on targets

it should be totally based on browsertargets., yeah agreed!

Here is a bare minimum demo of

  • leaving in both private class properties and methods.
  • transpiling away decorators

https://github.com/NullVoxPopuli/babel-transpilation-tests

@NullVoxPopuli
Copy link
Collaborator Author

NullVoxPopuli commented May 30, 2022

Progress on support for TS + components -- I have a working addon here:

A couple things:

@NullVoxPopuli NullVoxPopuli marked this pull request as ready for review June 4, 2022 15:26
@NullVoxPopuli
Copy link
Collaborator Author

I think this is now ready for re-review.

with v3 of rollup-plugin-ts, we have babel doing all transpilation now, so things should be in a much better spot than before.

I must note, that it seems in typescript > 4.3, declarationMaps are no longer generated -- but it seems a problem with TS-upstream, rather than anything here

},
],
[
resolve('@babel/plugin-proposal-decorators'),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to specify the class-properties plugin.

By only specifying the decorators plugin, we leave properties "native" and untranspiled, since all browsers support class properties now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is fine, but your explanation for why it's fine is incorrect:

since all browsers support class properties now.

Browser support has nothing to do with it. That is a concern for apps, not addons.

The only reason we need to transpile the decorators here is that acorn doesn't parse them, so rollup blows up. As soon as that is fixed, we should drop decorator transpilation, and that won't depend on whether any browsers have implemented decorators.

const { resolve } = require;

module.exports = {
presets: [resolve('@babel/preset-typescript')],
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using resolve here, because I hate "plugin not found errors" in babel. Typos are much easier to catch earlier, and resolve helps out with that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have both @babel/preset-typescript and @babel/plugin-transform-typescript? I think it should be one or the other. The only thing the preset does is install that plugin.

Copy link
Collaborator Author

@NullVoxPopuli NullVoxPopuli Jun 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plugin-transform-typescript is required when doing resolve in the babel.config.js (by eslint + node:recommended)
if I just use strings for the plugin names, the issue is side-stepped.

happy to do whatever you think is best here

// These are the modules that should get reexported into the traditional
// "app" tree. Things in here should also be in publicEntrypoints above, but
// not everything in publicEntrypoints necessarily needs to go here.
addon.appReexports([
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as an aside, is there anything we can do with appReexports and publicEntrypoints to either not need file extensions, or have some sort of assertions on over-specification of extensions? The plugins are very forgiving here, and I'm not sure what's "proper".

// It exists only to provide development niceties for you, like automatic
// template colocation.
// See `babel.config.json` for the actual Babel configuration!
ts({
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we also want to provide a sample tsconfig?

I've been using:

{
  "compilerOptions": {
    // Path resolution
    "baseUrl": "./src",
    "moduleResolution": "node",

    // types are built by rollup-plugin-ts
    "declarationDir": "./dist",
    "emitDeclarationOnly": true,
    "declaration": true,

    // Build settings
    "noEmitOnError": false,
    "module": "ESNext",
    "target": "ESNext",

    // Features
    "experimentalDecorators": true,
    "allowJs": false,
    "allowSyntheticDefaultImports": false,

    // Strictness / Correctness
    "strict": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "paths": {

      // No paths, no absolute imports, only node_modules imports allowed
      // But fallback for type-overrides and such
      "*": ["types/*"]
    }
  },
  "include": [
    "src/**/*",
    "types/*"
  ]
}

@ef4
Copy link
Contributor

ef4 commented Jun 9, 2022

Ideally, I would prefer if we can keep publicEntrypoints and appReexports arguments in terms of the publicly exposed filenames, which are always JS not TS. This is already how we treat components that are authored as standalone hbs files (in publicEntrypoints they appear as JS, not HBS, because the exact authoring format is an internal implementation detail).

@ef4
Copy link
Contributor

ef4 commented Jun 9, 2022

Thanks @NullVoxPopuli, did you confirm that the changes that we discussed and you implemented in 0b1408d actually work? I wasn't sure if a corresponding change is needed in the plugins.

@NullVoxPopuli
Copy link
Collaborator Author

Yeah, I have a ts demo that I started long ago, and the output dist folder looks good. the app has been failing for unrelated reasons though -- trying to resolve those before re-posting said demo app.

@NullVoxPopuli
Copy link
Collaborator Author

ok, so I gave up on https://github.com/NullVoxPopuli/ember-addon-v2-typescript-demo/
and decided to try on a project I already have working: https://github.com/NullVoxPopuli/ember-popperjs

the rollup build fails when publicEntrypoints points only at JS (and all files on disk are TS).

NullVoxPopuli added a commit to NullVoxPopuli/ember-popperjs that referenced this pull request Jun 9, 2022
@ef4
Copy link
Contributor

ef4 commented Jun 9, 2022

Yes, so let's fix the publicEntrypoints and/or appReexports plugins so they accept the .js extensions, and then keep the .js extensions in these examples.

@NullVoxPopuli
Copy link
Collaborator Author

NullVoxPopuli commented Jun 12, 2022

Yes, so let's fix the publicEntrypoints and/or appReexports plugins so they accept the .js extensions, and then keep the .js extensions in these examples.

Since we use the publicEntrypoints to match the file system, the only way to make this work would be to scan all files and assume that if a file matches without the specified extension in publicEntrypoints that that match (and all additional matching files?) are what we want to pull in to rollup -- brings up the question -- what happens when we encounter things rollup doesn't support? like, maybe src/components/demo/index.css would be matched, given: publicEntrypoints(['**/*.js'])

This seems... more complicated? maybe confusing?

@ef4
Copy link
Contributor

ef4 commented Jun 12, 2022

This is consistent with our handling of component hbs files.

It's not that we match anything, it's that we know a short list of extension mappings caused by the build. Right now we only predict the hbs -> js transformation, but we also have a list of extensions that normalize to js that we could also predict.

@NullVoxPopuli
Copy link
Collaborator Author

PR for that here: #1223

// but we need the ember plugins converted first
// (template compilation and co-location)
transpiler: 'babel',
browserslist: ['last 2 firefox versions', 'last 2 chrome versions'],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you have this? Browser support should be an app concern.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do need this -- the default is "something isn't close to what the browsers could natively support".

Without the browserslist,

  • argument-destructuring is polyfilled
  • nullish coalescing is polyfilled
  • optional chaining is polyfilled
  • etc

I agree that It absolutely should be an app concern to compile away these features, but I think addons should ship "as close to native" as they want to

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's definitely wrong to have this browserslist here. This implies that babel-preset-env is running. We need it not to run.

I think browserslist: false may disable babel-preset-env entirely, just based on perusing the source.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I have been doing this also here!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setting browserslist to false added poplyfills for ownKeys and objectSpread in my addon.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ef4 @NullVoxPopuli As discussed yesterday, I looked into this today. Our suspicion, that rollup-plugin-ts does some unexpected magic, seems to be right unfortunately. I filed this issue: wessberg/rollup-plugin-ts#189

transpiler: 'babel',
browserslist: ['last 2 firefox versions', 'last 2 chrome versions'],
tsconfig: {
fileName: 'tsconfig.json',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we omit this, as this is just declaring what is already the default?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, I'll remove. thanks!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just kidding, it's required when hooks are present

// Allows us to use `exports` to define types per export
// However, we can't use that feature until the minimum supported TS is 4.7+
declarationDir: './dist',
}),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this hook thing? Most of the options are already set in your tsconfig.json example, except for declarationMap, which we also could set there!?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah -- kinda more for understandability than anything -- having the rollup config control all output is easier to reason about than having two places where output can be configured

@NullVoxPopuli
Copy link
Collaborator Author

Gonna close this, because I think we've figured out everything we need to figure out w/r/t TS support in v2 addons (and there are demos of these in the wild).

Also with the upcoming --typescript support landing in both ember-cli and the addon-blueprint, the addon-blueprint will be the canonical (and tested) place to look for how to do TS

@NullVoxPopuli NullVoxPopuli deleted the addon-dev-rollup-ts-example branch October 10, 2022 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants