Commit cb402ffd authored by Hordur Freyr Yngvason's avatar Hordur Freyr Yngvason Committed by Denys Mishunov

Use a dynamic loader for window.gapi

This MR swaps out an implicit dependency on window.gapi for a lazy
loader using a script tag designed around the realities of how
gapi needs to be loaded.

Previously, we had an implicit global dependency on gapi, relying on a
DOMContentLoaded event listener in an entirely different module. This
was brittle and held back internal efforts to remove dependencies on the
DOMContentLoaded event.

See https://gitlab.com/gitlab-org/gitlab/-/issues/284997
parent 448760eb
// This is a helper module to lazily import the google APIs for the GKE cluster
// integration without introducing an indirect global dependency on an
// initialized window.gapi object.
export default () => {
if (window.gapiPromise === undefined) {
// first time loading the module
window.gapiPromise = new Promise((resolve, reject) => {
// this callback is set as a query param to script.src URL
window.onGapiLoad = () => {
resolve(window.gapi);
};
const script = document.createElement('script');
// do not use script.onload, because gapi continues to load after the initial script load
script.type = 'text/javascript';
script.async = true;
script.src = 'https://apis.google.com/js/api.js?onload=onGapiLoad';
script.onerror = reject;
document.head.appendChild(script);
});
}
return window.gapiPromise;
};
/* global gapi */
import Vue from 'vue';
import { deprecatedCreateFlash as Flash } from '~/flash';
import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue';
import GkeZoneDropdown from './components/gke_zone_dropdown.vue';
import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue';
import GkeSubmitButton from './components/gke_submit_button.vue';
import gapiLoader from './gapi_loader';
import store from './store';
......@@ -63,7 +63,7 @@ const gkeDropdownErrorHandler = () => {
Flash(CONSTANTS.GCP_API_ERROR);
};
const initializeGapiClient = () => {
const initializeGapiClient = (gapi) => () => {
const el = document.querySelector('.js-gke-cluster-creation');
if (!el) return false;
......@@ -86,13 +86,9 @@ const initializeGapiClient = () => {
.catch(gkeDropdownErrorHandler);
};
const initGkeDropdowns = () => {
if (!gapi) {
gkeDropdownErrorHandler();
return false;
}
return gapi.load('client', initializeGapiClient);
};
const initGkeDropdowns = () =>
gapiLoader()
.then((gapi) => gapi.load('client', initializeGapiClient(gapi)))
.catch(gkeDropdownErrorHandler);
export default initGkeDropdowns;
/* global gapi */
import * as types from './mutation_types';
import gapiLoader from '../gapi_loader';
const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) =>
new Promise((resolve, reject) => {
......@@ -36,57 +36,64 @@ export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBil
};
export const fetchProjects = ({ commit }) =>
gapiResourceListRequest({
resource: gapi.client.cloudresourcemanager.projects,
params: {},
commit,
mutation: types.SET_PROJECTS,
payloadKey: 'projects',
});
gapiLoader().then((gapi) =>
gapiResourceListRequest({
resource: gapi.client.cloudresourcemanager.projects,
params: {},
commit,
mutation: types.SET_PROJECTS,
payloadKey: 'projects',
}),
);
export const validateProjectBilling = ({ dispatch, commit, state }) =>
new Promise((resolve, reject) => {
const request = gapi.client.cloudbilling.projects.getBillingInfo({
name: `projects/${state.selectedProject.projectId}`,
});
gapiLoader()
.then((gapi) => {
const request = gapi.client.cloudbilling.projects.getBillingInfo({
name: `projects/${state.selectedProject.projectId}`,
});
commit(types.SET_ZONE, '');
commit(types.SET_MACHINE_TYPE, '');
commit(types.SET_ZONE, '');
commit(types.SET_MACHINE_TYPE, '');
return request.then(
return request;
})
.then(
(resp) => {
const { billingEnabled } = resp.result;
commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled));
dispatch('setIsValidatingProjectBilling', false);
resolve();
},
(resp) => {
(errorResp) => {
dispatch('setIsValidatingProjectBilling', false);
reject(resp);
return errorResp;
},
);
});
export const fetchZones = ({ commit, state }) =>
gapiResourceListRequest({
resource: gapi.client.compute.zones,
params: {
project: state.selectedProject.projectId,
},
commit,
mutation: types.SET_ZONES,
payloadKey: 'items',
});
gapiLoader().then((gapi) =>
gapiResourceListRequest({
resource: gapi.client.compute.zones,
params: {
project: state.selectedProject.projectId,
},
commit,
mutation: types.SET_ZONES,
payloadKey: 'items',
}),
);
export const fetchMachineTypes = ({ commit, state }) =>
gapiResourceListRequest({
resource: gapi.client.compute.machineTypes,
params: {
project: state.selectedProject.projectId,
zone: state.selectedZone,
},
commit,
mutation: types.SET_MACHINE_TYPES,
payloadKey: 'items',
});
gapiLoader().then((gapi) =>
gapiResourceListRequest({
resource: gapi.client.compute.machineTypes,
params: {
project: state.selectedProject.projectId,
zone: state.selectedZone,
},
commit,
mutation: types.SET_MACHINE_TYPES,
payloadKey: 'items',
}),
);
......@@ -19,6 +19,10 @@ export default (document) => {
initGkeDropdowns();
if (isProjectLevelCluster(page)) {
initGkeNamespace();
}
import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
.then(({ default: initCreateEKSCluster }) => {
const el = document.querySelector('.js-create-eks-cluster-form-container');
......@@ -28,8 +32,4 @@ export default (document) => {
}
})
.catch(() => {});
if (isProjectLevelCluster(page)) {
initGkeNamespace();
}
};
= javascript_include_tag 'https://apis.google.com/js/api.js'
- external_link_icon = sprite_icon('external-link')
- zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
- machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types'
......
......@@ -2,7 +2,6 @@
- page_title _('Kubernetes Cluster')
- active_tab = local_assigns.fetch(:active_tab, 'create')
- provider = params[:provider]
= javascript_include_tag 'https://apis.google.com/js/api.js'
= render_gcp_signup_offer
......
---
title: Dynamically load gapi on GKE cluster creation pages
merge_request: 49512
author:
type: other
import gapiLoader from '~/create_cluster/gke_cluster/gapi_loader';
describe('gapiLoader', () => {
// A mock for document.head.appendChild to intercept the script tag injection.
let mockDOMHeadAppendChild;
beforeEach(() => {
mockDOMHeadAppendChild = jest.spyOn(document.head, 'appendChild');
});
afterEach(() => {
mockDOMHeadAppendChild.mockRestore();
delete window.gapi;
delete window.gapiPromise;
delete window.onGapiLoad;
});
it('returns a promise', () => {
expect(gapiLoader()).toBeInstanceOf(Promise);
});
it('returns the same promise when already loading', () => {
const first = gapiLoader();
const second = gapiLoader();
expect(first).toBe(second);
});
it('resolves the promise when the script loads correctly', async () => {
mockDOMHeadAppendChild.mockImplementationOnce((script) => {
script.removeAttribute('src');
script.appendChild(
document.createTextNode(`window.gapi = 'hello gapi'; window.onGapiLoad()`),
);
document.head.appendChild(script);
});
await expect(gapiLoader()).resolves.toBe('hello gapi');
expect(mockDOMHeadAppendChild).toHaveBeenCalled();
});
it('rejects the promise when the script fails loading', async () => {
mockDOMHeadAppendChild.mockImplementationOnce((script) => {
script.onerror(new Error('hello error'));
});
await expect(gapiLoader()).rejects.toThrow('hello error');
expect(mockDOMHeadAppendChild).toHaveBeenCalled();
});
});
......@@ -71,10 +71,12 @@ describe('GCP Cluster Dropdown Store Actions', () => {
beforeAll(() => {
originalGapi = window.gapi;
window.gapi = gapi;
window.gapiPromise = Promise.resolve(gapi);
});
afterAll(() => {
window.gapi = originalGapi;
delete window.gapiPromise;
});
describe('fetchProjects', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment