ngxs-labs / data

NGXS Persistence API
https://ngxs-labs.github.io/data
81 stars 18 forks source link

State classes decorated with @Persistence() throw a TypeError when instantiated in Jest tests #622

Closed mflorence99 closed 3 years ago

mflorence99 commented 3 years ago

I half-heartedly started to report this back in September, but I had to move on and down-level to v3. Now I've had a day or two to look deeper and I can see the cause. It's not really a bug in your code but it is undesirable behavior I thought you should know about.

Problem

State classes decorated with @Persistence() throw a TypeError when instantiated in Jest tests. For example, TestBed.inject(SelectionState) will trigger:

TypeError: Class constructor SelectionState cannot be invoked without 'new'

This behavior is new in v4 and did not occur in prior versions.

Analysis

The stacktrace leads to code in the @Persistence() decorator:

return class extends stateClass {
  constructor(...args) {
    super(...args);
    registerStorageProviders(ensureProviders(repositoryMeta, this, options));
  }
};

This code is new in v4. It is propagated unchanged into the FESM bundle that Angular itself uses node_modules/@ngxs-labs/data/fesm2015/ngxs-labs-data-decorators.js (via vendor.js) and no issues are observed when running the Angular app.

However, the code is translated in the UMD bundle node_modules/@ngxs-labs/data/bundles/ngxs-labs-data-decorators.umd.js used by Jest. I've added a comment to show exactly what triggers the TypeError.

return /** @class */ (function (_super) {
  __extends(class_1, _super);
  function class_1() {
    var args = [];
    for (var _i = 0; _i < arguments.length; _i++) {
      args[_i] = arguments[_i];
    }
    // NOTE: _super.apply() triggers a TypeError
    var _this = _super.apply(this, __spread(args)) || this;
    storage.registerStorageProviders(
      storage.ensureProviders(repositoryMeta, _this, options)
    );
    return _this;
  }
  return class_1;
})(stateClass);

Resolution

I found it easiest to hack a post-install patch to change the offending line to var _this = this. That's just a hack though and I don't have a good suggestion for a fix, except that the v3 code worked and maybe there's a play to change the return to the prior pattern.

splincode commented 3 years ago

@mflorence99 unfortunately, I have little time left and have been busy with other projects lately, if you are interested, send PR

lneninger commented 3 years ago

Any update for this bug?

mflorence99 commented 3 years ago

My apologies -- I had to move on before I could figure which code was responsible for generating the failing code. I satisfied myself with the following patch code that I run as a post-install step. It powers my projects https://github.com/mflorence99/lintel and https://github.com/mflorence99/ternimal.

import * as fs from 'fs';

interface Patch {
  from: RegExp;
  to: string;
}

interface Patches {
  [fileName: string]: Patch[];
}

// @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
const toRegExp = (str: string, flags = 'g'): RegExp => {
  return new RegExp(str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'), flags);
};

/* eslint-disable @typescript-eslint/naming-convention */
const patches: Patches = {
  'node_modules/@ngxs-labs/data/bundles/ngxs-labs-data-decorators.umd.js': [
    // @see https://github.com/ngxs-labs/data/issues/622
    {
      from: toRegExp('var _this = _super.apply(this, __spread(args)) || this;'),
      to: 'var _this = args.length ? new _super(...args) : this;'
    }
  ]
};

Object.keys(patches).forEach((fileName) => {
  let code = fs.readFileSync(fileName, 'utf8');
  patches[fileName].forEach((patch) => {
    code = code.replace(patch.from, patch.to);
  });
  fs.writeFileSync(fileName, code);
});