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 Vue from 'vue';
import { deprecatedCreateFlash as Flash } from '~/flash'; import { deprecatedCreateFlash as Flash } from '~/flash';
import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue';
import GkeZoneDropdown from './components/gke_zone_dropdown.vue'; import GkeZoneDropdown from './components/gke_zone_dropdown.vue';
import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue';
import GkeSubmitButton from './components/gke_submit_button.vue'; import GkeSubmitButton from './components/gke_submit_button.vue';
import gapiLoader from './gapi_loader';
import store from './store'; import store from './store';
...@@ -63,7 +63,7 @@ const gkeDropdownErrorHandler = () => { ...@@ -63,7 +63,7 @@ const gkeDropdownErrorHandler = () => {
Flash(CONSTANTS.GCP_API_ERROR); Flash(CONSTANTS.GCP_API_ERROR);
}; };
const initializeGapiClient = () => { const initializeGapiClient = (gapi) => () => {
const el = document.querySelector('.js-gke-cluster-creation'); const el = document.querySelector('.js-gke-cluster-creation');
if (!el) return false; if (!el) return false;
...@@ -86,13 +86,9 @@ const initializeGapiClient = () => { ...@@ -86,13 +86,9 @@ const initializeGapiClient = () => {
.catch(gkeDropdownErrorHandler); .catch(gkeDropdownErrorHandler);
}; };
const initGkeDropdowns = () => { const initGkeDropdowns = () =>
if (!gapi) { gapiLoader()
gkeDropdownErrorHandler(); .then((gapi) => gapi.load('client', initializeGapiClient(gapi)))
return false; .catch(gkeDropdownErrorHandler);
}
return gapi.load('client', initializeGapiClient);
};
export default initGkeDropdowns; export default initGkeDropdowns;
/* global gapi */
import * as types from './mutation_types'; import * as types from './mutation_types';
import gapiLoader from '../gapi_loader';
const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) => const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
...@@ -36,57 +36,64 @@ export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBil ...@@ -36,57 +36,64 @@ export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBil
}; };
export const fetchProjects = ({ commit }) => export const fetchProjects = ({ commit }) =>
gapiResourceListRequest({ gapiLoader().then((gapi) =>
resource: gapi.client.cloudresourcemanager.projects, gapiResourceListRequest({
params: {}, resource: gapi.client.cloudresourcemanager.projects,
commit, params: {},
mutation: types.SET_PROJECTS, commit,
payloadKey: 'projects', mutation: types.SET_PROJECTS,
}); payloadKey: 'projects',
}),
);
export const validateProjectBilling = ({ dispatch, commit, state }) => export const validateProjectBilling = ({ dispatch, commit, state }) =>
new Promise((resolve, reject) => { gapiLoader()
const request = gapi.client.cloudbilling.projects.getBillingInfo({ .then((gapi) => {
name: `projects/${state.selectedProject.projectId}`, const request = gapi.client.cloudbilling.projects.getBillingInfo({
}); name: `projects/${state.selectedProject.projectId}`,
});
commit(types.SET_ZONE, ''); commit(types.SET_ZONE, '');
commit(types.SET_MACHINE_TYPE, ''); commit(types.SET_MACHINE_TYPE, '');
return request.then( return request;
})
.then(
(resp) => { (resp) => {
const { billingEnabled } = resp.result; const { billingEnabled } = resp.result;
commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled)); commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled));
dispatch('setIsValidatingProjectBilling', false); dispatch('setIsValidatingProjectBilling', false);
resolve();
}, },
(resp) => { (errorResp) => {
dispatch('setIsValidatingProjectBilling', false); dispatch('setIsValidatingProjectBilling', false);
reject(resp); return errorResp;
}, },
); );
});
export const fetchZones = ({ commit, state }) => export const fetchZones = ({ commit, state }) =>
gapiResourceListRequest({ gapiLoader().then((gapi) =>
resource: gapi.client.compute.zones, gapiResourceListRequest({
params: { resource: gapi.client.compute.zones,
project: state.selectedProject.projectId, params: {
}, project: state.selectedProject.projectId,
commit, },
mutation: types.SET_ZONES, commit,
payloadKey: 'items', mutation: types.SET_ZONES,
}); payloadKey: 'items',
}),
);
export const fetchMachineTypes = ({ commit, state }) => export const fetchMachineTypes = ({ commit, state }) =>
gapiResourceListRequest({ gapiLoader().then((gapi) =>
resource: gapi.client.compute.machineTypes, gapiResourceListRequest({
params: { resource: gapi.client.compute.machineTypes,
project: state.selectedProject.projectId, params: {
zone: state.selectedZone, project: state.selectedProject.projectId,
}, zone: state.selectedZone,
commit, },
mutation: types.SET_MACHINE_TYPES, commit,
payloadKey: 'items', mutation: types.SET_MACHINE_TYPES,
}); payloadKey: 'items',
}),
);
...@@ -19,6 +19,10 @@ export default (document) => { ...@@ -19,6 +19,10 @@ export default (document) => {
initGkeDropdowns(); initGkeDropdowns();
if (isProjectLevelCluster(page)) {
initGkeNamespace();
}
import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
.then(({ default: initCreateEKSCluster }) => { .then(({ default: initCreateEKSCluster }) => {
const el = document.querySelector('.js-create-eks-cluster-form-container'); const el = document.querySelector('.js-create-eks-cluster-form-container');
...@@ -28,8 +32,4 @@ export default (document) => { ...@@ -28,8 +32,4 @@ export default (document) => {
} }
}) })
.catch(() => {}); .catch(() => {});
if (isProjectLevelCluster(page)) {
initGkeNamespace();
}
}; };
= javascript_include_tag 'https://apis.google.com/js/api.js'
- external_link_icon = sprite_icon('external-link') - external_link_icon = sprite_icon('external-link')
- zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones' - 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' - machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types'
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
- page_title _('Kubernetes Cluster') - page_title _('Kubernetes Cluster')
- active_tab = local_assigns.fetch(:active_tab, 'create') - active_tab = local_assigns.fetch(:active_tab, 'create')
- provider = params[:provider] - provider = params[:provider]
= javascript_include_tag 'https://apis.google.com/js/api.js'
= render_gcp_signup_offer = 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', () => { ...@@ -71,10 +71,12 @@ describe('GCP Cluster Dropdown Store Actions', () => {
beforeAll(() => { beforeAll(() => {
originalGapi = window.gapi; originalGapi = window.gapi;
window.gapi = gapi; window.gapi = gapi;
window.gapiPromise = Promise.resolve(gapi);
}); });
afterAll(() => { afterAll(() => {
window.gapi = originalGapi; window.gapi = originalGapi;
delete window.gapiPromise;
}); });
describe('fetchProjects', () => { 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