18

I'm writing a Chrome Extension (here) for my library that uses angular for its UI. It works great on pages that don't use angular, but it causes issues with pages that do have angular. For example, on the Angular docs page:

Uncaught Error: [$injector:modulerr] Failed to instantiate module docsApp due to:
  Error: [$injector:nomod] Module 'docsApp' is not available! You either misspelled the module name or forgot to load it. If registering a module ensure that you specify the dependencies as the second argument.
  http://errors.angularjs.org/1.2.7/$injector/nomod?p0=docsApp
at chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:78:14
at chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1528:19
at ensure (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1453:40)
at module (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1526:16)
at chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:3616:24
at Array.forEach (native)
at forEach (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:302:13)
at loadModules (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:3610:7)
at createInjector (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:3550:13)
at doBootstrap (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1298:22)
http://errors.angularjs.org/1.2.7/$injector/modulerr?p0=docsApp&p1=Error%3A…xtension%3A%2F%2Fcfgbockhpgdlmcdlcbfmflckllmdiljo%2Fangular.js%3A1298%3A22) angular.js:78

The weird thing is that it seems to happen whether my extension actually uses angular or not. All I need to reproduce the issue is to include angular in my manifest.json as a content_script and this error is thrown. Any ideas of how I could make this work without messing up an angular site would be greatly appreciated.

Like I said, it doesn't matter whether I actually use angular or not, but this is all I'm doing to use it:

makeWishForAnchors(); // This just loads the global genie object. I don't believe it's related.

var lamp = '<div class="genie-extension"><div ux-lamp lamp-visible="genieVisible" rub-class="visible" local-storage="true"></div></div>';
$('body').append(lamp);

angular.module('genie-extension', ['uxGenie']);
angular.bootstrap($('.genie-extension')[0], ['genie-extension']);

Thanks!

kentcdodds
  • 27,113
  • 32
  • 108
  • 187

3 Answers3

25

The problem

As soon as Angular is injected, it parses the DOM looking for an element with the ng-app directive. If one is found Angular will bootstrap automatically. This becomes a problem when a page uses Angular itself, because (although they have separate JS execution contexts) the page and the content script share the same DOM.

The solution

You need to prevent your Angular instance (by "your" I mean the one injected by your extension as a content script) from automatically bootstrapping. Normally you would just omit the ng-app directive and you would be good to go, but since you do not have control over the original DOM (nor do you want to break the page's functionality) this is not an option.

What you can do it use manual bootstrapping for your Angular app in conjunction with deferred bootstrapping (to prevent your Angular from trying to automatically bootstrap the page's Angular app).

At the same time, you need to "protect" (i.e. hide) your app's root element from the page's Angular instance. To achieve this, you can wrap your root element in a parent element with the ngNonBindable directive, so the page's Angular instance will leave it alone.

Summarizing the steps from the above docs, you need to do the following:

  1. Prepend window.name with NG_DEFER_BOOTSTRAP! prior to injecting your Angular.
    E.g. inject a tiny script (before angluar.js) containing just one line:

    window.name = 'NG_DEFER_BOOTSTRAP!' + window.name;
    
  2. Wrap your app's root element in a parent element with the attribute ng-non-bindable:

    var wrapper = ...    // <div ng-non-bindable></div>
    wrapper.appendChild(lamp);   // or whatever your root element is
    document.body.appendChild(wrapper);
    
  3. In your app's main script, manually bootstrap your Angular app:

    var appRoot = document.querySelector('#<yourRootElemID');
    angular.bootstrap(appRoot, ['genie-extension']);
    

Fine print: I haven't tested it myself, but I promise to do so soon !


UPDATE

The code below is intended as a proof of concept for the approach described above. Basically, it is a demo extension that loads an Angular-powered content-script into any http:/https: page whenever the browser-action button is clicked.

The extension takes all necessary precautions in order not to interfere with (or get broken by) the page's own Angular instance (if any).

Finally, I had to add a third requirement (see the updated solution description above) to protect/hide the content-script's Angular app from the page's Angular instance.
(I.e. I wrapped the root element in a parent element with the ngNonBindable directive.)

manifest.json:

{
  "manifest_version": 2,
  "name": "Test Extension",
  "version": "0.0",

  "background": {
    "persistent": false,
    "scripts": ["background.js"]
  },

  "browser_action": {
    "default_title": "Test Extension"
//    "default_icon": {
//      "19": "img/icon19.png",
//      "38": "img/icon38.png"
//    },
  },

  "permissions": ["activeTab"]
}

background.js:

// Inject our Angular app, taking care
// not to interfere with page's Angular (if any)
function injectAngular(tabId) {
  // Prevent immediate automatic bootstrapping
  chrome.tabs.executeScript(tabId, {
    code: 'window.name = "NG_DEFER_BOOTSTRAP!" + window.name;'
  }, function () {
    // Inject AngularJS
    chrome.tabs.executeScript(tabId, {
      file: 'angular-1.2.7.min.js'
    }, function () {
      // Inject our app's script
      chrome.tabs.executeScript(tabId, {file: 'content.js'});
    });
  });
}

// Call `injectAngular()` when the user clicks the browser-action button
chrome.browserAction.onClicked.addListener(function (tab) {
  injectAngular(tab.id);
});

content.js:

// Create a non-bindable wrapper for the root element
// to keep the page's Angular instance away
var div = document.createElement('div');
div.dataset.ngNonBindable = '';
div.style.cssText = [
  'background:  rgb(250, 150, 50);',
  'bottom:      0px;',
  'font-weight: bold;',
  'position:    fixed;',
  'text-align:  center;',
  'width:       100%;',
  ''].join('\n');

// Create the app's root element (everything else should go in here)
var appRoot = document.createElement('div');
appRoot.dataset.ngController = 'MyCtrl as ctrl';
appRoot.innerHTML = 'Angular says: {{ctrl.message}}';

// Insert elements into the DOM
document.body.appendChild(div);
div.appendChild(appRoot);

// Angular-specific code goes here (i.e. defining and configuring
// modules, directives, services, filters, etc.)
angular.
  module('myApp', []).
  controller('MyCtrl', function MyCtrl() {
    this.message = 'Hello, isolated world !';
  });

/* Manually bootstrap the Angular app */
window.name = '';   // To allow `bootstrap()` to continue normally
angular.bootstrap(appRoot, ['myApp']);
console.log('Boot and loaded !');

Fine print:
I have conducted some preliminary tests (with both Angular and non-Angular webpages) and everything seems to work as expected. Yet, I have by no means tested this approach thoroughly !


Should anyone be interested, this is what it took to "Angularize" Genie's lamp.

gkalpak
  • 47,844
  • 8
  • 105
  • 118
  • Looks like a workable solution. I've never heard of `NG_DEFER_BOOTSTRAP` before. I'll give it a try... – kentcdodds Jan 10 '14 at 17:08
  • I successfully prevented angular from loading the site's module, and I'm also initializing my angular module, but how do I ensure that the site's module is loaded as well...? – kentcdodds Jan 10 '14 at 18:22
  • The site's module should be loaded normally (do you have any indication of the opposite ?). If setting `window.name` is what worries you, note that content scripts **[live in an isolated world](http://developer.chrome.com/extensions/content_scripts.html#execution-environment)**, meaning the `window` objects are separate. Quoting the docs: _Each isolated world sees its own version of the object. Assigning to the object affects your independent copy of the object._ – gkalpak Jan 10 '14 at 18:34
  • I think there was something messed up with my state. It seems to be loading properly with your changes. However, when I do my own bootstrapping (step 2) it throws the same error, even though I'm bootstrapping a single div. I think it's because I'm bootstrapping a child of something that's already bootstrapped, and that seems to not be allowed... – kentcdodds Jan 10 '14 at 18:41
  • 1
    I was afraid it might happen. Depending on what exactly your content script does, you might get away with injecting your elements in `document.documentElement` (intead of `document.body`). In any case I have some possible workarounds in mind. Let me check them out and get back to you. – gkalpak Jan 10 '14 at 18:47
  • I'm afraid that the documentElement wont do it for me. Thanks for looking into this for me! :D – kentcdodds Jan 10 '14 at 20:16
  • I noticed too that `documentElement` won't get us anywhere. Check out see my updated answer. I was able to get a very basic extension working, but it might not work _out-of-the-box_ for a more complex extension. If you try it and encounter any errors, you can provide an **[SSCCE](http://sscce.org)** or steps to reproduce the problem based on the code in your extension's repo and I'll be more than happy to look further into it. – gkalpak Jan 10 '14 at 21:16
  • @kentcdodds: Sorry, but I am kind of qurious: Did you give it a try ? Did you face any problems ? – gkalpak Jan 11 '14 at 21:29
  • I did give it a try and unfortunately it didn't quite work for me because I need the script to load when the page is loaded, not when the user clicks on an icon. Also, it just didn't seem to work for me to not interfere with the built in angular module... And I don't see how that could be possible. I'm going to see if I can remove my angular dependency... You're more than welcome to give it a shot to make it work on the repo [here](http://github.com/kentcdodds/genies-lamp) – kentcdodds Jan 11 '14 at 22:09
  • You bet I will :) Just to know what I should be looking for: What exactly is the issue ? – gkalpak Jan 11 '14 at 22:51
  • @kentcdodds: There you go: https://db.tt/UjZZ0A7R (please let me know if you still face any problems) – gkalpak Jan 12 '14 at 15:47
  • Wow! That's awesome. It totally works without any hiccups! Thanks for helping. I could put this in the git repository myself, but I would really prefer to give you the credit for your contribution. Would you be willing to make a pull request with these changes? Here's the repository again: http://github.com/kentcdodds/genies-lamp – kentcdodds Jan 13 '14 at 16:00
  • That's really nice of you ! Unfortunately, I am not a `GitHub` kind of guy (I really like free private repos), so... It's OK though, I take pleasure in fixing stuff and getting my answers upvoted/accepted. (Lame - I know - i am working on it.) – gkalpak Jan 13 '14 at 16:28
  • You gotta get on GitHub sometime! Anyway, thanks for the help. I really appreciate it. If you want, you can use the extension from the Chrome Web Store [here](https://chrome.google.com/webstore/detail/genies-lamp/pimmaneflgfbknjkjdlnffagpgfeklko?utm_source=chrome-ntp-icon) :) – kentcdodds Jan 13 '14 at 17:33
0

Answer of @ExpertSystem is pretty cool. But there on more case with same error. If you define angular in manifest.json in content-script section, like below:

"content_scripts": [
    {
      "css": ["app.css"],
      "js": [
        "libs/jquery/dist/jquery.min.js",
        "libs/angular/angular.min.js",
        "content.js"
      ],
      "matches": ["\u003Call_urls\u003E"]
    }
  ],

You may faced this error (like I am)

Community
  • 1
  • 1
S Panfilov
  • 16,641
  • 17
  • 74
  • 96
0

@ExpertSystem 's answer is good, but it doesn't work for me, I think it may not a solution for each scenarios, so in the end I decide to edit the source file of angular.js.

I found angular.js will listen a ready event at the end of the file (in 1.4.6):

jqLite(document).ready(function() {
  angularInit(document, bootstrap);
});

Delete these lines, and everything clear.

If you use Bower, you can add a simple hook to do that when bower install or bower update.

In .bowerrc file:

{
  "scripts" : {
    "postinstall" : "node ./strip"
  }
}

In strip.js:

const fs  = require( 'fs' ) ,
filePath  = './bower_components/angular/angular.js' ,
angularJs = fs.readFileSync( filePath , 'utf8' ) ,
deleteStr = /jqLite\(document\)\.ready\(function\(\)[\s\S]+?\}\);/ ,
isDeleted = angularJs.search( deleteStr ) < 0;

if ( !isDeleted ) {
  fs.writeFileSync( filePath , angularJs.replace( deleteStr , '' ) );
  console.log( 'successful disabled auto-bootstrap' );
} else {
  console.log( 'auto-bootstrap already disabled.' );
}
Mica Lee
  • 36
  • 7