Build SharePoint Framework solutions for on-premises SharePoint with ANY version of React, TypeScript or Office UI Fabric React

Any version?

-Yes, any.

Including the latest versions of React, TypeScript, etc. ?

-Yes.

The problem

SharePoint Framework is supported not only by SharePoint Online but by on-premises SharePoint as well (2019 and 2016 with Feature Pack). SharePoint Framework Yeoman generator has different options for different SharePoint versions and it generates different project templates depending on the environment selection.

On-premises SharePoint is always behind SharePoint Online in terms of features and codebase. And the same issue applies to SharePoint Framework. If you generate a "Hello world" SharePoint Framework web part for SharePoint 2019, you will see that it uses React 15.6, TypeScript 2.4 and Office UI Fabric React (OUIFR) 5.21. The most recent versions (as of Sept. 2021) are React 17.x, TypeScript 4.x and OUIFR (now called Fluent UI React) 8.x.

Now you see the issue - you always have to work with an older version of packages. You miss a lot of potential features, bug fixes, and other things. Additionally, from a developer perspective, it's not exciting to work with outdated technologies or frameworks. Will Microsoft update yeoman generator for on-premises to add support to the most recent version of packages? I don't think so. On-premises are not in the priority list today. 

For those who want to jump and explore the code right away - the full source code for this post is here at GitHub.

A way to solve the issue of the outdated packages

As post's title says it's possible to overcome this problem. Let's dig into a theory a bit.

A bit of theory

Let's take a look at what yeoman generates for us in a plain "Hello world" react based web part:

import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
  BaseClientSideWebPart,
  IPropertyPaneConfiguration,
  PropertyPaneTextField
} from '@microsoft/sp-webpart-base';

import * as strings from 'HelloWorldWebPartStrings';
import HelloWorld from './components/HelloWorld';
import { IHelloWorldProps } from './components/IHelloWorldProps';

export interface IHelloWorldWebPartProps {
  description: string;
}

export default class HelloWorldWebPart extends BaseClientSideWebPart<IHelloWorldWebPartProps> {

  public render(): void {
    const element: React.ReactElement<IHelloWorldProps> = React.createElement(
      HelloWorld,
      {
        description: this.properties.description
      }
    );

    ReactDom.render(element, this.domElement);
  }
.....
}

At a bare minimum, we have a file, which exports some class, which extends BaseClientSideWebPart. SharePoint Framework build pipeline later moves everything into the javascript bundle with help of webpack. From webpack's perspective export default class HelloWorldWebPart is nothing but the export of a module, which will be transformed in require call. 

And now the trick: what if we move our HelloWorldWebPart class into the external module (with our own dependencies), and then simply reference it from HelloWorldWebPart.ts file? This is how our code will look like in HelloWorldWebPart.ts:

const externalWebPart: any = require('../hello-world-webpart-bundle');

export default externalWebPart;

What is this hello-world-webpart-bundle? This is another completely independent javascript package (created with webpack), which holds our code for the web part. 

So how to do that?

Implementation

First of all, we obviously can't upgrade SharePoint Framework related packages to the most recent version. Thus they will remain untouched (SharePoint Framework version 1.4.1 for SharePoint 2019).

Scaffold SPFx project for SharePoint 2019

Let's start with scaffolding SharePoint Framework solution for SharePoint 2019 with yeoman. run yo @microsoft/sharepoint, select SharePoint 2019 environment, web part component and React framework. 

Create externals library to host your web part code

Now we need to create another separate and independent folder to store the actual code for the web part. Create a new folder (I called it "external") and init a new npm project with command "npm init". Your folder structure will be as on the image below:

Add dependencies to your external project. You should add all dependencies used in the original SharePoint Framework solution (because we're going to use packages from SharePoint Framework modules). You should also add other dependencies needed to build your external lib with webpack. 

Here are my resulting dependencies in externals lib:

"dependencies": {
    "@microsoft/sp-core-library": "1.4.1",
    "@microsoft/sp-lodash-subset": "1.4.1",
    "@microsoft/sp-office-ui-fabric-core": "^1.8.2",
    "@microsoft/sp-webpart-base": "1.4.1",
    "@types/react": "^16.8.19",
    "@types/react-dom": "^16.8.4",
    "@uifabric/styling": "^7.0.3",
    "@uifabric/utilities": "^7.0.3",
    "office-ui-fabric-react": "^7.5.3",
    "react": "^16.8.6",
    "react-dom": "^16.8.6"
  },
  "devDependencies": {
    "@microsoft/loader-load-themed-styles": "^1.7.160",
    "@types/es6-promise": "^3.3.0",
    "@types/webpack-env": "^1.13.9",
    "concurrently": "^4.1.0",
    "css-loader": "^2.1.1",
    "css-modules-typescript-loader": "^2.0.3",
    "fork-ts-checker-webpack-plugin": "^1.3.4",
    "gulp": "^4.0.2",
    "node-sass": "^4.12.0",
    "sass-loader": "^7.1.0",
    "style-loader": "^0.23.1",
    "ts-loader": "^6.0.2",
    "tslint": "^5.17.0",
    "tslint-config-standard": "^8.0.1",
    "typescript": "^3.5.1",
    "webpack": "^4.32.2",
    "webpack-cli": "^3.3.2",
    "webpack-merge": "^4.2.1",
    "webpack-visualizer-plugin": "^0.1.11"
  }

Note that I use the latest versions of React, TypeScript, and OUIFR.

Don't forget to run npm install in external lib to restore all dependencies.

Now create the same folder structure in external lib as in your original SharePoint Framework solution, i.e. src/webparts/helloWorld/. Then simply copy helloWorld\components\ folder from SharePoint Framework web part into the corresponding folder in externals project. Completely copy HelloWorldWebPart.ts file as well. Your structure now will look like:

VSCode shouldn't complain about anything, because we have all dependencies installed from original SharePoint Framework 1.4.1 solution. 

Add webpack build into the external lib

Ok, we have our code moved to the external lib, but we don't have a way to make a javascript package out of it. Let's do it!

You should add a webpack.config.js file. Here is my resulting webpack file which works just fine:

const path = require('path');
const merge = require('webpack-merge');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const Visualizer = require('webpack-visualizer-plugin');

module.exports = merge({
    target: "web",
    entry: {
        'hello-world-webpart-bundle': path.join(__dirname, '../src/webparts/helloWorld/HelloWorldWebPart.ts'),
        'my-new-webpart-bundle': path.join(__dirname, '../src/webparts/myNewWebPart/MyNewWebPartWebPart.ts')
    },
    output: {
        path: path.join(__dirname, '../dist'),
        publicPath: "/dist/",
        filename: '[name].js',
        libraryTarget: "umd",
        library: "[name]"
    },
    performance: {
        hints: false
    },
    stats: {
        errors: true,
        colors: true,
        chunks: false,
        modules: false,
        assets: false
    },
    externals: [
        /^@microsoft\//,
        'HelloWorldWebPartStrings',
        'MyNewWebPartWebPartStrings',
        'ControlStrings'],
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                loader: 'ts-loader',
                options: {
                    transpileOnly: true
                },
                exclude: /node_modules/
            },
            {
                test: /\.(jpg|png|woff|eot|ttf|svg|gif|dds)$/,
                use: [{
                    loader: "@microsoft/loader-cased-file",
                    options: {
                        name: "[name:lower]_[hash].[ext]"
                    }
                }]
            },
            {
                test: /\.css$/,
                use: [
                    {
                        loader: "@microsoft/loader-load-themed-styles",
                        options: {
                            async: true
                        }
                    },
                    {
                        loader: 'css-loader'
                    }
                ]
            },
            {
                test: function (fileName) {
                    return fileName.endsWith(".module.scss");   // scss modules support
                },
                use: [
                    {
                        loader: "@microsoft/loader-load-themed-styles",
                        options: {
                            async: true
                        }
                    },
                    'css-modules-typescript-loader',
                    {
                        loader: 'css-loader',
                        options: {
                            modules: true
                        }
                    }, // translates CSS into CommonJS
                    "sass-loader" // compiles Sass to CSS, using Node Sass by default
                ]
            },
            {
                test: function (fileName) {
                    return !fileName.endsWith(".module.scss") && fileName.endsWith(".scss");  // just regular .scss
                },
                use: [
                    {
                        loader: "@microsoft/loader-load-themed-styles",
                        options: {
                            async: true
                        }
                    },
                    "css-loader", // translates CSS into CommonJS
                    "sass-loader" // compiles Sass to CSS, using Node Sass by default
                ]
            }
        ]
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js']
    },
    plugins: [new ForkTsCheckerWebpackPlugin({
        tslint: true
    })]
});

I'm not going to dig into every single line, just a few important things here:

  • entry node points to HelloWorldWebPart.ts file, because, well, it's our entry point. The name of a javascript package is hello-world-webpart-bundle
  • externals section is extremely important. First of we should ignore all @microsoft/* modules, otherwise, they will be included in our external javascript package. That's not something we need. Additionally, SharePoint supplies localized strings for us using a separate module. That's why you should ignore HelloWorldWebPartStrings as well.
  • I use css-modules-typescript-loader in order to generate the same TypeScript declarations for styles as SharePoint Framework does it. 
  • I use @microsoft/loader-load-themed-styles in order to support themable styles in .scss files, i.e. things like $ms-color-themeDark
  • I use @microsoft/loader-cased-file to load static files, i.e. images or fonts.

There are two additional webpack files to support dev and prod scenarios. They extend base common webpack file.

Dev:

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
    mode: "development",
    devtool: "source-map"
});

Prod:

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
    mode: 'production'
});

If you run webpack --config webpack/webpack.dev.js it will generate development javascript bundle out of your web part. Cool, that's something we were looking for!

We still have two more issues here. 

Fix localized resources

As said, resources are loaded separately in a separate module by SharePoint Framework pipeline. Thus you have to manage resources in your original SharePoint Framework solution.

It works, however, our external lib lacks type definitions because they are in a folder with SharePoint Framework solution. Let's create a gulp task, which copies TypeScript d.ts definition files from a folder with SharePoint Framework solution to our externals lib folder.

Add a new gulpfile.js into the externals project. Create a new task to copy definitions:

const { src, dest, watch } = require('gulp');

function copyLocalizedResources() {
    return src('../spfx/src/webparts/**/loc/*.d.ts')
        .pipe(dest('./src/webparts'));
}

Fix static files, i.e. images, fonts

Sometimes you have a need to reference a static file in your external solution. Most likely that's an image. Your spfx solution knows nothing about your file, that's why you will see "Not found" errors in console when you try to use images. They simply won't load. 

To fix it, you should add a sub-task to gulpfile.js in spfx folder, which copies all static files from dist folder in external solution to dist and temp/deploy (for packaging) in spfx folder:

const externalsFolder = "external";

const copyStaticFilesSubtask = build.subTask('copy-static-files', function (gulp, buildOptions, done) {
  this.log('Copying static files...');

  gulp.src(`../${externalsFolder}/dist/*.{png,jpg,svg,gif,woff,eot,ttf}`)
  .pipe(gulp.dest("./dist"))
  .pipe(gulp.dest("./temp/deploy"));

  done();
});

build.rig.addPostBuildTask(copyStaticFilesSubtask);

build.initialize(gulp);

Fix web part reload when source code changes

In a normal web part, as soon as you change a file, it will refresh workbench. It doesn't work for our approach, because all our sources are in the externals lib. Let's add another gulp task to request reload of the web part. You can do that by simply re-writing index.ts file in a SharePoint Framework solution root. This is exactly what it does:

function triggerTargetWebPartReload() {
    return src('../spfx/src/index.ts')
        .pipe(dest('../spfx/src/'))
}

And finally to make things more convenient, let's add watch task to make reload happens on every code change:

exports.watch = function () {
    watch('../spfx/src/webparts/**/loc/*.d.ts', {
        ignoreInitial: false
    }, copyLocalizedResources);

    watch('./dist/*.js', triggerTargetWebPartReload);
}

Update npm scripts

We're almost done. For convenience let's add a few npm scripts into the externals project:

"scripts": {
    "watch-webpack": "webpack --config webpack/webpack.dev.js --watch",
    "watch-gulp": "gulp watch",
    "watch": "concurrently \"npm:watch-*\"",
    "dev": "webpack --config webpack/webpack.dev.js",
    "build": "webpack --config webpack/webpack.prod.js"
  },

Fix import in SharePoint Framework web part

As our external module with web part's code is ready, let's update HelloWorldWebPart.ts file inside the SharePoint Framework solution folder. Simply replace everything with:

/* tslint:disable */

const externalWP: any = require('../../../../external/dist/hello-world-webpart-bundle');

export default externalWP.default;

TSLint complains about some issues, but we don't need tslint here anymore, thus it was disabled. 

PnP Reusable React [property] controls usage

If you want to use Reusable React controls or Reusable property pane controls you should perform additional steps:

  • Install @pnp/spfx-controls-react (and \ or properties) in both external and spfx folders
  • In spfx folder make sure that config/config.json was updated with a new value "ControlStrings" or "PropertyControlStrings"
  • add css-loader for webpack in the external folder, webpack.common.js
  • add 'ControlStrings' (and \ or 'PropertyControlStrings') to externals section of webpack.common.js, because it's something which SPFx will handle

The sample on the github contains PnP Reusable Control example, so you can learn more there. 

Note on OUIFR 7.x

OUIFR 7.x has one compatibility issue with SharePoint 2019. OUIFR added new properties to a theme called effects and spacing. There was an issue regarding this change in the spfx docs repo. SharePoint 2019 doesn't have effects and spacing properties. To make it work you should provide default values for those properties. You can work around it by using the below code:

/**
 * Fix to make it work with OUIFR 7.x
 */

import { GlobalSettings } from '@uifabric/utilities/lib/GlobalSettings';
import { getTheme } from '@uifabric/styling/lib/styles/theme';

const customizations = GlobalSettings.getValue('customizations');
const theme = getTheme();
(customizations as any).settings.theme.effects = { ...theme.effects };
(customizations as any).settings.theme.spacing = { ...theme.spacing };
(customizations as any).settings.theme.fonts = { ...theme.fonts };

/**
 * End of fix
 */

If you're ok to use OUIFR 6.x, then you don't need this fix. 

Now we've done with configurations and fixes. We're ready for development.

Note on SharePoint 2016

Drake Harnen found an issue with SharePoint 2016 and different type definitions used by your application and node_modules/@microsoft/ related packages. To fix it you should delete all @types/react[-dom] folders from @microsoft/* folders in your "externals" folder. To do that, simply add rimraf dependency and add a post-install command, which removes mentioned folders:

"postinstall": "npx rimraf node_modules/@microsoft/**/node_modules/@types/react/ && npx rimraf node_modules/@microsoft/**/node_modules/@types/react-dom/"

You can also do that for SharePoint 2019 if you have strange typings issues (because SPFx 1.4 uses 15.x react type definitions). 

Development flow

In your externals lib folder run 

$ npm run watch

It will spin up a webpack watch process, and gulp watch process as well. Gulp watcher is responsible for reloading our web part when source code changes, and for coping TypeScript definitions for resources. 

In SharePoint Framework solution folder simply run

$ gulp serve

Production flow

For production in your externals repo run 

$ npm run build

This will pack your solution using production settings. 

Then in SharePoint Framework solution folder run

$ gulp bundle --ship 
$ gulp package-solution --ship

This will pack your SharePoint Framework package, it will be ready to distribution. 

How to add a new web part?

The process of adding a new web part a bit more complicated rather than with yeoman generator:

  1. Scaffold a new web part in SPFx folder
  2. Copy folder with a web part to externals folder
  3. In SharePoint Framework web part's source remove everything and re-export a module (like we did in Fix import in SharePoint Framework web part)
  4. Update webpack.common.js - new entry and new externals for localizations
  5. Fix fabric import in scss file, i.e. @import '@microsoft/sp-office-ui-fabric-core/dist/sass/_SPFabricCore.scss';
  6. Fix import * as styles in the component

Pros and cons

+ Pros

The main pros is that we use the latest versions of packages. That was the main goal as well. 

- Cons

    - Complexity

It's a way more complicated solution rather than one build with SharePoint Framework Yeoman generator. You have to create your own webpack build, add gulp task. The process of adding new web parts is also more complicated. 

    - Resulting bundle size

As you see, this approach bundles all dependencies together (React, OUIFR), thus resulting javascript file will be bigger. 

For example with only React and without OUIFR resulting bungle will be approximately 130KB in size, while the same configuration for regular yeoman generated package is 10KB. OOB one is 13x times smaller.

However, as soon as you add OUIFR things will slightly change. I added a few OUIFR controls using "safe" import cost (import {smth} from "'office-ui-fabric-react/lib/smth"). OOB SharePoint Framework with yeoman will consume 180KB, the customized solution will consume 300KB. In that case, OOB is only 1.6x times smaller, because the latest OUIFR package is smaller than OUIFR 5.x.

Conclusion

It's possible to use the latest versions of packages in your SharePoint Framework solutions for on-premises. However, it requires some additional work and webpack skills. You shouldn't use it for SharePoint Online, because SharePoint Framework generator for Online is updated on a regular basis. 

I haven't tried this approach on production scenarios, however, I will try if there is a chance. 

The full source code for this post is here at GitHub.