wessberg / rollup-plugin-ts

A TypeScript Rollup plugin that bundles declarations, respects Browserslists, and enables seamless integration with transpilers such as babel and swc
MIT License
495 stars 33 forks source link

How do I use babel for decorators instead of TS? #174

Closed NullVoxPopuli closed 2 years ago

NullVoxPopuli commented 2 years ago

Question

This part of the documentation says that typescript is used for TS-specific features..

Removing TypeScript-specific features such as types, type-only imports, enums, and TypeScript decorators.

but decorators are not TS-specific.

TS decorators are different from the @babel/plugin-proposal-decorators output -- how can I use this babel plugin for decorators instead of TS (forcing a tslib dependency, which I don't want :sweat_smile: )

My babel config:

'use strict';

const { resolve } = require;

module.exports = {
  plugins: [
    [
      resolve('@babel/plugin-transform-typescript'),
      {
        allowDeclareFields: true,
        onlyRemoveTypeImports: true,
        optimizeConstEnums: true,
      },
    ],
    [
      resolve('@babel/plugin-proposal-decorators'),
      {
        legacy: true,
      },
    ],
    [
      resolve('@babel/plugin-proposal-class-properties'),
      {
        // loose: true,
      },
    ],
  ],
};

my ts setup:

    ts({
      transpiler: 'babel',
      browserslist: ['last 2 firefox versions', 'last 2 chrome versions'],
      tsconfig: {
        fileName: 'tsconfig.json',
        hook: (config) => ({
          ...config,
          declaration: true,
          declarationMap: true,
          declarationDir: './dist',
          removeComments: false,
        }),
      },
    }),
NullVoxPopuli commented 2 years ago

Did some more digging, the tslib's decorator implementation looks very similar to the legacy babel implementation:

// output from tslib
function __decorate(decorators, target, key, desc) {
  var c = arguments.length,
      r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc,
      d;

  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") 
    r = Reflect.decorate(decorators, target, key, desc);
  else 
    for (var i = decorators.length - 1; i >= 0; i--) 
      if (d = decorators[i]) 
        r = (
          c < 3 
          ? d(r) 
          : c > 3 
            ? d(target, key, r) 
            : d(target, key)
          ) || r;

  return c > 3 && r && Object.defineProperty(target, key, r), r;
}

export { __decorate as _ };
import { _ as __decorate } from '../../tslib.es6-ddb164bc.js';
import { tracked } from '@glimmer/tracking';

class Demo extends Component {
 // ...
}

__decorate([tracked], Demo.prototype, "active", void 0);

And interop with the assumed-to-be-compiled-by-babel decorator (tracked) and this code from my test library seems to be ok?

but, I suppose my question still remains though -- since babel can handle this, how do we let babel handle this?

wessberg commented 2 years ago

Hi there.

You're right that decorators are also being standardized as part of TC39 and finally seem to be coming along nicely. But as of today, TypeScript decorators are still treated by TypeScript as part of the TypeScript superset (like enums), and is still something you opt in to use with the experimentalDecorators tsconfig option. The decorators proposal has changed a lot throughout the years. At some point, the decorators proposal diverged quite a bit from how TypeScript decorators work (and the syntax from that proposal was implemented as part of @babel/plugin-proposal-decorators, but you could set the legacy: true option, like you do, to have it transpile according to the old proposal, which was a lot like TypeScript decorators, which is also why the implementation in tslib is practically identical to the one in the legacy implementation of the babel plugin).

That is why decorators are removed from TypeScript even when you target ESNext. There is in fact no way to preserve them after the TypeScript compiler has been doing its thing such that other tools, like Babel, can enter in a later step and transform the decorator syntax. To better understand this, imagine running tsc first, and then Babel in a secondary step. You wouldn't be able to preserve the decorators for Babel to later parse and transpile, so long as you used the TypeScript compiler and its APIs first. rollup-plugin-ts does something similar to this, by building up an incremental TypeScript BuilderProgram and then emitting with target ESNext before delegating the results to Babel for the actual syntax lowering.

However, this will all probably change now that decorators have entered stage 3 in the standards process. When it eventually arrives in stage 4 and becomes part of the next major version of EcmaScript, potentially ES2023, then TypeScript decorators will align with the standardized feature and be preserved when you target ESNext or ES2023 as the target of a tsconfig, and by then tools like rollup-plugin-ts will preserve them too such that babel will be responsible of transpiling them down to whatever browsers/runtimes you're targeting.

I hope this explanation makes sense. If not, feel free to comment again, and I'll gladly elaborate.

chriskrycho commented 2 years ago

@wessberg that explanation does indeed make sense and is in fact eminently reasonable. However, there are a number of cases where you need to use TS for type checking while using Babel (or other tools) for emit for decorators. A key example here—and the motivating one for @NullVoxPopuli's comment—is the entire Ember ecosystem, which standardized on using the Babel legacy decorator transform and indeed using Babel for all emit (using TS only for type checking) back in 2018 precisely because of the interop hazards that exist when trying to use both Babel and TS's decorators. The bugs are many and subtle!

Given that (a) TypeScript has yet to implement support for the Stage 3 decorators proposal, and the earliest we'd expect to see it is in TS 4.8, coming in mid-August; (b) there is not yet any clear migration path for existing users of the legacy transforms for either Babel or TypeScript; and (c) there are many users who rely on the ability to do all emit via Babel etc., rather than using tsc for any part of the emit pipeline—would you be open to adding a flag to entirely opt out of tsc-based emit? That would be enormously helpful!

wessberg commented 2 years ago

Hi there @chriskrycho.

I guess it would make sense to support delegating the full responsibility of syntax transformation to whatever transpiler has been chosen, such as babel. It could lead to some performance gains, albeit minor, since a TypeScript builder program would still be constructed based on the source file(s), since it is needed not just for diagnostics, but especially so for declaration files (which too need a proper type checker for the relationships between TS symbols to be correct in the declaration bundling phase).

In fact, I can see some value in not even exposing this as a config option, but enabling this behavior by default if @babel/preset-typescript can be found inside the combined Babel config, as that signals an intent from the library consumer to delegate the full responsibility to Babel. This is, of course, "magic", which has downsides as well, such as less transparency. On the other hand, this library has always been about making things "just work" with minimal to absolutely no configuration, and I'm trying to stick to that philosophy. Of course, for some users, doing the first emit with the TypeScript Compiler APIs will have some advantages, but in these cases these users can just leave out @babel/preset-typescript from their babel configs.

What do you think about a solution such as this one?

chriskrycho commented 2 years ago

I think the default should be to use only the specified transpiler for emitting code if a non-tsc option is supplied there, using tsc only for emitting declarations in that case, but with the ability to opt into using tsc for its own specific features (const enum, decorators, any others I’m not thinking of). That would be a breaking change for existing usage so you might need

I would very much prefer “good defaults, no magic, configurable if the defaults don’t fit with your needs.”

wessberg commented 2 years ago

If we do it like that, then we will have to add @babel/preset-typescript as an (optional) peer dependency, since otherwise just selecting transpiler: "babel" in the plugin config would lead to crashes, as the syntax wouldn't be supported.

I'm very much in agreement with you generally in terms of magic. For rollup-plugin-ts, there is quite a bit of magic in place here, which historically has been a key reason why this plugin has tended to "just work", where it has been a battle to configure everything correctly with other solutions. For example, if @babel/plugin-transform-runtime is found in the combined babel config, some options are forced on it such as to use ES modules and to import helpers instead of inline them to ensure that it plays nice with Rollup that will ultimately inline them (unless marked as external in the Rollup config), which builds up an internal module dependency graph of ES modules, as well as to let Rollup potentially code split the usage of helpers and avoid redundancy.

In terms of the "good, but overridable defaults" principle, in practice here what tends to happen is that people bring in their babel configs from other projects, and these very often come with configuration options that simply won't produce great Rollup bundles. And instead of writing a ton of documentation of best practices and which options to disable when combining with Rollup, it has been a good solution to forcefully ignore some of those options that simply don't play work well with this integration.

There are other examples of magic too, such as splitting babel configs into two if babel-minify plugins are identified, to ensure that the minification related plugins only run once per chunk, whereas others run once per file. All this to say that where I tend to do the opposite with other libraries, with rollup-plugin-ts I've always been hesitant to add config options if I can come up with a solution that "just works", even if it means breaking transparency at times. I believe that's what keeps this library's promise of just working with absolutely no config options given intact, even if there's some Babel, tsconfig, or swc config files in the root of the directory that may come with options that could otherwise be problematic.

Okay, sorry for the wall of text there. But that's the context. I'm still fairly sure I would prefer a default behavior of only doing the full emit with Babel if I can find @babel/preset-typescript in the babel config, as I feel like that principle has worked well for this plugin in the past, but I'm also open for letting users explicitly decide this behavior with a config option. In that case, I'm thinking of extending the transpiler option like this:

// It can be a string
{
    transpiler: "babel"
}

// But it can also be a record
{
    transpiler: { 
        typescriptSyntax: "typescript",
        otherSyntax: "babel"
    }
}

With that option, we're back to fully allowing customizing the transpilation behavior, while still retaining good defaults and respecting the input babel/swc configs as much as possible while still ignoring the ones that could break Rollup or produce inefficient bundles.

How does that sound?

wessberg commented 2 years ago

This has been implemented as part of v3.0.0.

What I ended up implementing was a solution in which the chosen transpiler is used for the entire syntax transformation. I also ended up marking @babel/preset-typescript as an optional peer dependency for when babel is selected as a transpiler.

This behavior is fully customizable, since the transpiler option now optionally takes an options record, as described above. This means that transpilers can be mixed, e.g.:

transpiler: { 
        typescriptSyntax: "typescript",
        otherSyntax: "babel"
    }

When mixed like so, @babel-preset-typescript is not required, as it isn't relevant to that configuration. However, when used simply like transpiler: "babel", it is.

chriskrycho commented 2 years ago

Outstanding. Thank you!

simonihmig commented 2 years ago

I tested the new release, with regard to the problem discussed here, and it worked perfectly! Thanks so much @wessberg 🙏

NullVoxPopuli commented 2 years ago

Anyone see anything obvious I did wrong here? https://github.com/NullVoxPopuli/ember-popperjs/pull/166/files

I still get:

ember-popperjs/ember-popperjs on  simpler-build took 11s 
❯ pnpm build:js

> ember-popperjs@3.0.0 build:js /home/psego/Development/NullVoxPopuli/ember-popperjs/ember-popperjs
> rollup -c ./rollup.config.js

 → dist...
(!) Unresolved dependencies
https://rollupjs.org/guide/en/#warning-treating-module-as-external-dependency
@babel/runtime/helpers/esm/applyDecoratedDescriptor (imported by src/components/popper-j-s.ts)
@babel/runtime/helpers/esm/initializerWarningHelper (imported by src/components/popper-j-s.ts)
[!] (plugin Typescript) TS2354: This syntax requires an imported helper but module 'tslib' cannot be found.
src/components/popper-j-s.ts (45:3)

45   @tracked isShown = false;
     ~~~~~~~~

 ELIFECYCLE  Command failed with exit code 1.
NullVoxPopuli commented 2 years ago

Ah! https://github.com/wessberg/rollup-plugin-ts/blob/master/documentation/explainer/why_is_babel-plugin-transform-runtime_and_tslib_included_by_default.md

so tslib is required for parsing (cause Acorn doesn't support decorators). I confirm that even with tslib in my devDependencies, my decorators are using the babel decorator polyfill. Most excellent :100:

wessberg commented 2 years ago

That's correct, tslib isn't actually being used in the actual transformation phase in your case, but still the TypeScript compiler options declare importHelpers: true, which triggers this error.

However, I've come to realize there's actually no need to enforce importHelpers: true for the case where babel/swc is used for the entirety of the actual transformation, so from the next release of rollup-plugin-ts, this error will go away for the case where you're using babel/swc for everything.

As for the warning about @babel/runtime/helpers/esm/applyDecoratedDescriptor and @babel/runtime/helpers/esm/initializerWarningHelper being treated as external dependencies, that typically either means you've forgotten to install @babel/runtime, which is a peer dependency, or that you marked it as external in your rollup config.