diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 1aaefcaa13b7a44239961c7a9c702654268161ec..abd13a30156350de5137499dcca3c37ce5a8380f 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -37,6 +37,7 @@ import initBroadcastNotifications from './broadcast_notification';
 import { initTopNav } from './nav';
 
 import 'ee_else_ce/main_ee';
+import 'jh_else_ce/main_jh';
 
 applyGitLabUIConfig();
 
diff --git a/app/assets/javascripts/main_jh.js b/app/assets/javascripts/main_jh.js
new file mode 100644
index 0000000000000000000000000000000000000000..13a6b8f3d3d17ba91c280a8ca524ef5c482ecb1a
--- /dev/null
+++ b/app/assets/javascripts/main_jh.js
@@ -0,0 +1 @@
+// This is an empty file to satisfy jh_else_ce import for the JH main entry point
diff --git a/config/webpack.config.js b/config/webpack.config.js
index b81b56110418f3fb2984507b7d7cbbf6e014f337..0b130565d3255922e9cc18b83a44b824430fbb21 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -157,6 +157,9 @@ const alias = {
   // the following resolves files which are different between CE and EE
   ee_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'),
 
+  // the following resolves files which are different between CE and JH
+  jh_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'),
+
   // override loader path for icons.svg so we do not duplicate this asset
   '@gitlab/svgs/dist/icons.svg': path.join(
     ROOT_PATH,
@@ -180,10 +183,13 @@ if (IS_EE) {
 if (IS_JH) {
   Object.assign(alias, {
     jh: path.join(ROOT_PATH, 'jh/app/assets/javascripts'),
+    jh_component: path.join(ROOT_PATH, 'jh/app/assets/javascripts'),
+    jh_empty_states: path.join(ROOT_PATH, 'jh/app/views/shared/empty_states'),
     jh_icons: path.join(ROOT_PATH, 'jh/app/views/shared/icons'),
     jh_images: path.join(ROOT_PATH, 'jh/app/assets/images'),
     jh_spec: path.join(ROOT_PATH, 'jh/spec/javascripts'),
     jh_jest: path.join(ROOT_PATH, 'jh/spec/frontend'),
+    jh_else_ce: path.join(ROOT_PATH, 'jh/app/assets/javascripts'),
   });
 }
 
@@ -519,6 +525,15 @@ module.exports = {
         );
       }),
 
+    !IS_JH &&
+      new webpack.NormalModuleReplacementPlugin(/^jh_component\/(.*)\.vue/, (resource) => {
+        // eslint-disable-next-line no-param-reassign
+        resource.request = path.join(
+          ROOT_PATH,
+          'app/assets/javascripts/vue_shared/components/empty_component.js',
+        );
+      }),
+
     new CopyWebpackPlugin({
       patterns: [
         {
@@ -634,10 +649,12 @@ module.exports = {
       }),
 
     new webpack.DefinePlugin({
-      // This one is used to define window.gon.ee and other things properly in tests:
+      // These are used to define window.gon.ee, window.gon.jh and other things properly in tests:
       'process.env.IS_EE': JSON.stringify(IS_EE),
-      // This one is used to check against "EE" properly in application code
+      'process.env.IS_JH': JSON.stringify(IS_JH),
+      // These are used to check against "EE" properly in application code
       IS_EE: IS_EE ? 'window.gon && window.gon.ee' : JSON.stringify(false),
+      IS_JH: IS_JH ? 'window.gon && window.gon.jh' : JSON.stringify(false),
       // This is used by Sourcegraph because these assets are loaded dnamically
       'process.env.SOURCEGRAPH_PUBLIC_PATH': JSON.stringify(SOURCEGRAPH_PUBLIC_PATH),
     }),
diff --git a/jest.config.base.js b/jest.config.base.js
index 997f3c254b4dceaf43982019950202c15be39b93..3ace87c49bc62800242ca0aa0a0e19fa97ea28cd 100644
--- a/jest.config.base.js
+++ b/jest.config.base.js
@@ -1,10 +1,12 @@
 const IS_EE = require('./config/helpers/is_ee_env');
 const isESLint = require('./config/helpers/is_eslint');
+const IS_JH = require('./config/helpers/is_jh_env');
 
 module.exports = (path, options = {}) => {
   const {
     moduleNameMapper: extModuleNameMapper = {},
     moduleNameMapperEE: extModuleNameMapperEE = {},
+    moduleNameMapperJH: extModuleNameMapperJH = {},
   } = options;
 
   const reporters = ['default'];
@@ -29,6 +31,9 @@ module.exports = (path, options = {}) => {
     testMatch.push(`<rootDir>/ee/${glob}`);
   }
 
+  if (IS_JH) {
+    testMatch.push(`<rootDir>/jh/${glob}`);
+  }
   // workaround for eslint-import-resolver-jest only resolving in test files
   // see https://github.com/JoinColony/eslint-import-resolver-jest#note
   if (isESLint(module)) {
@@ -41,8 +46,11 @@ module.exports = (path, options = {}) => {
     '^~(/.*)$': '<rootDir>/app/assets/javascripts$1',
     '^ee_component(/.*)$':
       '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js',
+    '^jh_component(/.*)$':
+      '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js',
     '^shared_queries(/.*)$': '<rootDir>/app/graphql/queries$1',
     '^ee_else_ce(/.*)$': '<rootDir>/app/assets/javascripts$1',
+    '^jh_else_ce(/.*)$': '<rootDir>/app/assets/javascripts$1',
     '^helpers(/.*)$': '<rootDir>/spec/frontend/__helpers__$1',
     '^vendor(/.*)$': '<rootDir>/vendor/assets/javascripts$1',
     [TEST_FIXTURES_PATTERN]: '<rootDir>/tmp/tests/frontend/fixtures$1',
@@ -70,6 +78,19 @@ module.exports = (path, options = {}) => {
     collectCoverageFrom.push(rootDirEE.replace('$1', '/**/*.{js,vue}'));
   }
 
+  if (IS_JH) {
+    const rootDirJH = '<rootDir>/jh/app/assets/javascripts$1';
+    Object.assign(moduleNameMapper, {
+      '^jh(/.*)$': rootDirJH,
+      '^jh_component(/.*)$': rootDirJH,
+      '^jh_else_ce(/.*)$': rootDirJH,
+      '^jh_jest/(.*)$': '<rootDir>/jh/spec/frontend/$1',
+      ...extModuleNameMapperJH,
+    });
+
+    collectCoverageFrom.push(rootDirJH.replace('$1', '/**/*.{js,vue}'));
+  }
+
   const coverageDirectory = () => {
     if (process.env.CI_NODE_INDEX && process.env.CI_NODE_TOTAL) {
       return `<rootDir>/coverage-frontend/jest-${process.env.CI_NODE_INDEX}-${process.env.CI_NODE_TOTAL}`;
@@ -107,6 +128,7 @@ module.exports = (path, options = {}) => {
     testEnvironment: '<rootDir>/spec/frontend/environment.js',
     testEnvironmentOptions: {
       IS_EE,
+      IS_JH,
     },
   };
 };
diff --git a/jest.config.integration.js b/jest.config.integration.js
index 92296fb751e19676ff3c1c7e785e01c6f51b6e77..da8e813a2cb5778a8a823bd778dfe26f3b9a4f35 100644
--- a/jest.config.integration.js
+++ b/jest.config.integration.js
@@ -8,9 +8,13 @@ module.exports = {
     moduleNameMapper: {
       '^test_helpers(/.*)$': '<rootDir>/spec/frontend_integration/test_helpers$1',
       '^ee_else_ce_test_helpers(/.*)$': '<rootDir>/spec/frontend_integration/test_helpers$1',
+      '^jh_else_ce_test_helpers(/.*)$': '<rootDir>/spec/frontend_integration/test_helpers$1',
     },
     moduleNameMapperEE: {
       '^ee_else_ce_test_helpers(/.*)$': '<rootDir>/ee/spec/frontend_integration/test_helpers$1',
     },
+    moduleNameMapperJH: {
+      '^jh_else_ce_test_helpers(/.*)$': '<rootDir>/jh/spec/frontend_integration/test_helpers$1',
+    },
   }),
 };
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 16a8b5f959e5935c8a9e5ddbb289ce073e037533..6297ee2a9cc9acab0b10104908fc0c60428c8fec 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -35,6 +35,7 @@ def add_gon_variables
       gon.first_day_of_week      = current_user&.first_day_of_week || Gitlab::CurrentSettings.first_day_of_week
       gon.time_display_relative  = true
       gon.ee                     = Gitlab.ee?
+      gon.jh                     = Gitlab.jh?
       gon.dot_com                = Gitlab.com?
 
       if current_user