Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular 7 SSR - problems with NgZone

I've recently moved my company's website from React to Angular, since most our projects were already on Angular 7. Being the "use-the-latest-and-greatest" person that I am, I decided to implement server-side rendering to get the google page speed rating close to that 100/100 (currently 42/100). I've been tinkering with it for the better part of the week now, unsuccessfully - the latest roadblock has been especially hard for me to overcome. Here's a brief info about my setup, then I'll get into the details:

  • NodeJS 8.9.1
  • Angular 7 latest
  • Webpack 4.26.0
  • @ngtools/webpack 7.0.5
  • Not using angular-cli
  • AoT setup
  • single page app

This is the error I'm getting when I try to render the layout.html file that's set up for SSR:

TypeError: Cannot read property 'subscribe' of undefined
at new ApplicationRef (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:43263:37)
at _createClass (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:46296:20)
at _createProviderInstance (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:46258:26)
at initNgModule (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:46190:32)
at new NgModuleRef_ (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:46918:9)
at createNgModuleRef (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:46907:12)
at Object.debugCreateNgModuleRef [as createNgModuleRef] (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:48738:12)
at NgModuleFactory_.module.exports.NgModuleFactory_.create (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:49466:25)
at C:\code\lemmsoftWebsite\repo\node_modules\@angular\core\bundles\core.umd.js:14656:47
at ZoneDelegate.module.exports.ZoneDelegate.invoke (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:139510:26)
at Object.onInvoke (C:\code\lemmsoftWebsite\repo\node_modules\@angular\core\bundles\core.umd.js:14194:37)
at ZoneDelegate.module.exports.ZoneDelegate.invoke (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:139509:32)
at Zone.module.exports.Zone.run (C:\code\lemmsoftWebsite\repo\public\site\serverBuild\main.js:139260:43)
at NgZone.run (C:\code\lemmsoftWebsite\repo\node_modules\@angular\core\bundles\core.umd.js:14108:32)
at PlatformRef.bootstrapModuleFactory (C:\code\lemmsoftWebsite\repo\node_modules\@angular\core\bundles\core.umd.js:14654:27)
at renderModuleFactory (C:\code\lemmsoftWebsite\repo\node_modules\@angular\platform-server\bundles\platform-server.umd.js:1033:43)
at View.module.app.engine (C:\code\lemmsoftWebsite\repo\modules\clients\site\layout\index.js:60:4)
at View.render (C:\code\lemmsoftWebsite\repo\node_modules\express\lib\view.js:135:8)
at tryRender (C:\code\lemmsoftWebsite\repo\node_modules\express\lib\application.js:640:10)
at Function.render (C:\code\lemmsoftWebsite\repo\node_modules\express\lib\application.js:592:3)
at ServerResponse.render (C:\code\lemmsoftWebsite\repo\node_modules\express\lib\response.js:1008:7)
at C:\code\lemmsoftWebsite\repo\modules\clients\site\layout\index.js:83:9

After much reading through the main.js bundle file, I've pinpointed the problem to the following:

var ApplicationRef = /** @class */ (function () {
    /** @internal */
    function ApplicationRef(_zone, _console, _injector, _exceptionHandler, _componentFactoryResolver, _initStatus) {
        var _this = this;
        this._zone = _zone; // in this method, the _zone argument is {}, so there is no onMicrotaskEmpty method in it => when this._zone.onMicrotaskEmpty.subscribe() is attempted, we get "Cannot read property 'subscribe' of undefined"
        this._console = _console;
        this._injector = _injector;
        this._exceptionHandler = _exceptionHandler;
        this._componentFactoryResolver = _componentFactoryResolver;
        this._initStatus = _initStatus;
        this._bootstrapListeners = [];
        this._views = [];
        this._runningTick = false;
        this._enforceNoNewChanges = false;
        this._stable = true;
        // more code
    }
    // more code
}

In this method, the _zone argument is {}, so there is no onMicrotaskEmpty method in it => when this._zone.onMicrotaskEmpty.subscribe() is attempted, we get "Cannot read property 'subscribe' of undefined". I kept on digging through the stack trace - this the previous step, where new ApplicationRef is invoked and _zone is passed as {}:

function _createClass(ngModule, ctor, deps) {
    var len = deps.length;
    switch (len) {
        case 0:
            return new ctor();
        case 1:
            return new ctor(resolveNgModuleDep(ngModule, deps[0]));
        case 2:
            return new ctor(resolveNgModuleDep(ngModule, deps[0]), resolveNgModuleDep(ngModule, deps[1]));
        case 3:
            return new ctor(resolveNgModuleDep(ngModule, deps[0]), resolveNgModuleDep(ngModule, deps[1]), resolveNgModuleDep(ngModule, deps[2]));
        default:
            // this is where we get some insight into the cause of the error
            var depValues = new Array(len);
            for (var i = 0; i < len; i++) {
                depValues[i] = resolveNgModuleDep(ngModule, deps[i]);
            }
            // if we do console.log(deps[0], depValues[0]), which is _zone, we get interesting stuff... 
            return new (ctor.bind.apply(ctor, Object(tslib__WEBPACK_IMPORTED_MODULE_0__["__spread"])([void 0], depValues)))();
    }
}

This is where we get some insight into the cause of the error - in the 'default' block of the switch. If we console.log(deps[0], depValues[0]) after the for loop, which is _zone, we get interesting stuff:

// deps[0]
{ flags: 0,
  token:
   { [Function: NgZone]
     isInAngularZone: [Function],
     assertInAngularZone: [Function],
     assertNotInAngularZone: [Function] },
  tokenKey: 'NgZone_29' }
// depValues[0]
{}

So, here's the culprit, I thought! 'resolveNgModuleDep' screws it up! So I kept on digging:

function resolveNgModuleDep(data, depDef, notFoundValue) {
    if (notFoundValue === void 0) { notFoundValue = Injector.THROW_IF_NOT_FOUND; }
    var former = setCurrentInjector(data);
    try {
        if (depDef.flags & 8 /* Value */) {
            return depDef.token;
        }
        if (depDef.flags & 2 /* Optional */) {
            notFoundValue = null;
        }
        if (depDef.flags & 1 /* SkipSelf */) {
            return data._parent.get(depDef.token, notFoundValue);
        }
        var tokenKey_1 = depDef.tokenKey;
        switch (tokenKey_1) {
            case InjectorRefTokenKey:
            case INJECTORRefTokenKey:
            case NgModuleRefTokenKey:
                return data;
        }
        var providerDef = data._def.providersByKey[tokenKey_1];
        var injectableDef = void 0;
        if (providerDef) {
            var providerInstance = data._providers[providerDef.index];
            if (providerInstance === undefined) {
                providerInstance = data._providers[providerDef.index] =
                    _createProviderInstance(data, providerDef);
            }
            return providerInstance === UNDEFINED_VALUE ? undefined : providerInstance;
        }
        else if ((injectableDef = getInjectableDef(depDef.token)) && targetsModule(data, injectableDef)) {
            var index = data._providers.length;
            data._def.providersByKey[depDef.tokenKey] = {
                flags: 1024 /* TypeFactoryProvider */ | 4096 /* LazyProvider */,
                value: injectableDef.factory,
                deps: [], index: index,
                token: depDef.token,
            };
            data._providers[index] = UNDEFINED_VALUE;
            return (data._providers[index] =
                _createProviderInstance(data, data._def.providersByKey[depDef.tokenKey]));
        }
        else if (depDef.flags & 4 /* Self */) {
            return notFoundValue;
		    }
        // there it is!
        return data._parent.get(depDef.token, notFoundValue);
    }
    finally {
        setCurrentInjector(former);
    }
}

There it is! Right before finally, on the line that returns data._parent.get(depDef.token, notFoundValue) - this is where depDef.token (which in our case is NgZone) is passed, notFoundValue is null. The returned object is just {}, hence all the troubles later on. This is as far as I've managed to get, I've been going back and forth trying to solve it from here, but to no avail. Believe me, I've searched through and through in stackoverflow and just in google; I've read a 1000 medium posts - no success. I don't use angular-cli because I like to customize my webpack config, among other things, but I doubt that's the reason, because angular-cli itself uses webpack under the hood. I'm going to paste a few additional stuff below in several snippets - my webpack config, the server method where the html and angular bundle is rendered, etc.

// the webpack config

'use strict'

const
	AngularCompilerPlugin = require( "@ngtools/webpack" ).AngularCompilerPlugin,
	BellOnBundlerErrorPlugin = require('bell-on-bundler-error-plugin'),
	BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin,
	path = require('path'),
	ProgressBarPlugin = require('progress-bar-webpack-plugin'),
	UglifyJsPlugin = require('uglifyjs-webpack-plugin'),
	webpack = require('webpack')


module.exports = (config, name) => {
	let includePath = config.clientPath,
		publicPath = path.join(config.publicPath, 'serverBuild'),
		libPath = path.join(__dirname, '../../lib'),
		nodeModulesPath = config.nodeModulesPath,
		include = [includePath]

	return {
		target: 'node',
		mode: 'none',
		entry: [
			path.join(includePath, 'polyfills.ts'),
			path.join(includePath, 'vendor.common.ts'),
			path.join(includePath, 'vendor.server.ts'),
			path.join(includePath, 'index.server.ts')
		],
		output: {
			path: publicPath,
			filename: '[name].js',
			chunkFilename: '[id].chunk.js',
			publicPath: '/dist/',
			libraryTarget: 'commonjs-module'
		},
		resolve: {
			extensions: ['.ts', '.js'],
			modules: ['node_modules', libPath]
		},
		module: {
			rules: [
				{
					test: /\.pug$/,
					include: [libPath, includePath],
					use: ['raw-loader', 'pug-html-loader']
				},
				{
					test: /\.css$/,
					include: [libPath, nodeModulesPath, includePath],
					exclude: [],
					use: ['to-string-loader', 'css-loader']
				},
				{
					test: /\.less$/,
					exclude: [],
					use: ['to-string-loader', 'css-loader', 'less-loader']
				},
				{
					test: /\.scss$/,
					include: [libPath, nodeModulesPath, includePath],
					exclude: [],
					use: ['raw-loader', 'sass-loader']
				},
				{
					test: /(?:\.ngfactory\.js|\.ngstyle\.js|\.ts)$/,
					include: [includePath, libPath],
					use: [{
						loader: '@ngtools/webpack'
					}],
					exclude: [/\.(spec|e2e)\.ts$/]
				},
				{
					test: /\.json$/,
					include,
					exclude: [],
					use: ["json2-loader"]
				}
			]
		},
		stats: 'verbose',
		plugins: [
			// new webpack.HotModuleReplacementPlugin(),
			new BellOnBundlerErrorPlugin(),
			new ProgressBarPlugin({
				format: '  build [:bar] (:percent) - (:elapsed seconds)',
				clear: false,
				complete: '#',
				summary: 'true'
			}),
			// new webpack.NamedModulesPlugin(),
			new AngularCompilerPlugin({
				tsConfigPath: path.join(__dirname, '../../tsconfig.server.json'),
				entryModule: path.join(includePath, 'app.server.ts#AppServerModule'),
				sourceMap: true
			})
		]
	}
}

// tsconfig.json and tsconfig.server.json
{
	"compilerOptions": {
		"baseUrl": ".",
		"target": "es6",
		"module": "es2015",
		"moduleResolution": "node",
		"emitDecoratorMetadata": true,
		"experimentalDecorators": true,
		"allowSyntheticDefaultImports": true,
		"sourceMap": true,
		"importHelpers": true,
		"strictNullChecks": false,
		"lib": [
			"es2015",
			"dom"
		],
		"typeRoots": [
			"node_modules/@types",
			"typings"
		],
		"types": [
			"hammerjs",
			"node"
		],
		"paths": {
			"ramster-ui/*": ["lib/ramster-ui/*"]
		}
	},
	"include": [
		"clients/**/*",
		"lib/ramster-ui/**/*"
	],
	"exclude": [
		"clients/**/*.spec.ts",
		"clients/**/*.e2e.ts"
	],
	"awesomeTypescriptLoaderOptions": {
		"forkChecker": true,
		"useWebpackText": true
	},
	"angularCompilerOptions": {
		"genDir": "./compiled",
		"skipMetadataEmit": true
	},
	"compileOnSave": false,
	"buildOnSave": false,
	"atom": {
		"rewriteTsconfig": false
	}
}


{
	"extends": "./tsconfig.json",
	"include": [
		"clients/**/polyfills.ts",
		"clients/**/vendor.common.ts",
		"clients/**/vendor.server.ts",
		"clients/**/app.server.ts",
		"clients/**/index.server.ts",
	],
	"exclude": [
		"clients/**/index.ts",
		"clients/**/vendor.browser.ts",
		"clients/**/app.ts",
		"clients/**/*.spec.ts",
		"clients/**/*.e2e.ts"
	],
	"awesomeTypescriptLoaderOptions": {
		"forkChecker": true,
		"useWebpackText": true
	},
	"angularCompilerOptions": {
		"genDir": "./compiled",
		"skipMetadataEmit": true
	},
	"compileOnSave": false,
	"buildOnSave": false,
	"atom": {
		"rewriteTsconfig": false
	}
}

// excerpts from my server setup

// this method is called before the server is started
	setup() {
		const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require(path.join(__dirname, '../../../../public/site/serverBuild', 'main'))
		// LAZY_MODULE_MAP is undefined for now
		this.module.app.engine('html', (_, options, callback) => {
			renderModuleFactory(
				AppServerModuleNgFactory, {}
			).then((html) => callback(null, html), (error) => callback(error))
		})
		this.module.app.set('view engine', 'html')
	}

// the method returned by "loadLayout" is mounted in expressjs
	loadLayout() {
		const {module} = this
		return function* (req, res, next) {
			try {
				res.render(path.join('../../../../public/site/layout.html'), {req, res})
			} catch (e) {
				req.locals.error = e
				next()
			}
		}
	}

// polyfills.ts
import 'core-js/es6'
import 'reflect-metadata'


// vendor.common.ts
import 'rxjs/add/operator/first'
import 'rxjs/add/operator/toPromise'
import 'popper.js'

import '@angular/common'
import '@angular/core'
import '@angular/flex-layout'
import '@angular/forms'
import '@angular/material'
import '@angular/router'


// vendor.server.ts
import 'zone.js/dist/zone-node'
import '@angular/platform-server'


// index.server.ts
import {enableProdMode} from '@angular/core'
if (process.env.NODE_ENV === 'production') {
	enableProdMode()
}
export * from './app.server.ngfactory'


// app.server.ts
import {NgModule} from '@angular/core'
import {ServerModule} from '@angular/platform-server'

@NgModule({
	imports: [
    ServerModule
  ],
  exports: [],
  declarations: [],
  providers: [],
  bootstrap: []
})
class AppServerModule {}

export {AppServerModule}

You'll see that I've trimmed down the server-side app to the very basics, so I can eliminate the error being caused by something I've written. Any help would be MUCH appreciated.

like image 437
Rumen Rumenov Avatar asked Nov 25 '18 09:11

Rumen Rumenov


People also ask

Why do we use NgZone?

In those cases, the NgZone service provides a run() method that allows you to run a function inside the Angular zone. This function, and all asynchronous operations in that function, triggers change detection automatically at the correct time.

Is Angular Universal production ready?

Yes, it will be a production build if your angular. json specifies so.

How does SSR work in Angular?

Angular Interview Q & A series Server side Rendering (SSR) is a modern technique to convert a Single Page Application (SPA) running in the browser into a server based application. Usually, in SPA, the server returns a simple index. html file with the reference to the JavaScript based SPA app.

Should I use Angular universal?

A primary benefit for using Angular Universal is that it improves web crawler support for enhanced Search Engine Optimization (SEO). With traditional client-side rendered SPAs, anything that is not in that shell of an . html is all rendered by the JavaScript.


1 Answers

ZoneJS does not work properly as an AMD module. You are using the SystemJS AMD extra and zone.js is bundled to UMD format -- this results in Zone being used as an AMD module which triggers the broken behavior

Full explanation, and issue on zone.js - https://github.com/angular/angular/issues/36827

Minimal demonstration of the problem - https://codesandbox.io/s/quirky-antonelli-2kusz?file=/index.html

Workaround - https://codesandbox.io/s/sweet-shape-18x7b

like image 88
Joel Denning Avatar answered Oct 08 '22 02:10

Joel Denning