Skip to content

feat: initial draft-implementation for the 2.0 API #895

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"docs": "typedoc src/index.ts && cp -r dist docs/dist && cp -r examples docs/examples",
"format": "eslint . --fix",
"lint": "eslint .",
"prepare": "rm -rf dist && rollup -c",
"prepack": "rm -rf dist && rollup -c",
"test": "jest src/*",
"test:e2e": "jest e2e/*"
},
Expand Down
42 changes: 42 additions & 0 deletions src/2.0/2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Proposed 2.0 API

The proposed API consists of just two main functions: `setOptions` to
configure the options and `importLibrary` to asynchronously load a library.
Both are exported as functions or with the default-export.

```ts
// using the default export
import ApiLoader from "@googlemaps/js-api-loader";

// initial configuration
ApiLoader.setOptions({ apiKey: "...", version: "weekly" });

// loading a library
const { Map } = await ApiLoader.importLibrary("maps");
```

```ts
// alternative, using named exports
import { setOptions, importLibrary } from "@googlemaps/js-api-loader";

const { Map } = await importLibrary("maps");
```

## Internal Behavior

- the ApiLoader doesn't do anything (except for storing the options) until
the `importLibrary` function is called for the first time. This allows
users to configure the loader in a central place of their application
even if the maps API isn't used on most pages.

- Once the importLibrary function is called, the options are frozen and
attempts to modify them will get ignored

- the first call to importLibrary initiates the bootstrapping, once the
maps API is loaded, importLibrary will directly forward to the
`google.maps.importLibrary` function.

- if an attempt to load the API fails, the loader will resolve all pending
importLibrary promises with an error and will retry loading with the next
importLibrary call. This allows users to implement their own handling of
unstable network conditions and the like
82 changes: 82 additions & 0 deletions src/2.0/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { APILoadingError, type APIOptions, bootstrapLoader } from "./loader";

// fixme: remove the second importLibrary signature and ApiLibraryMap interface
// once proper typings are implemented in @types/google.maps
// (https://github.com/googlemaps/js-types/issues/95)

interface APILibraryMap {
core: google.maps.CoreLibrary;
drawing: google.maps.DrawingLibrary;
elevation: google.maps.ElevationLibrary;
geocoding: google.maps.GeocodingLibrary;
geometry: google.maps.GeometryLibrary;
journeySharing: google.maps.JourneySharingLibrary;
maps: google.maps.MapsLibrary;
maps3d: google.maps.Maps3DLibrary;
marker: google.maps.MarkerLibrary;
places: google.maps.PlacesLibrary;
routes: google.maps.RoutesLibrary;
streetView: google.maps.StreetViewLibrary;
visualization: google.maps.VisualizationLibrary;
}

type APILibraryName = keyof APILibraryMap;

let isBootrapped_ = false;
let options_: APIOptions = {};
const libraries_: Partial<APILibraryMap> = {};

/**
* Sets the options for the Maps JavaScript API.
* Has to be called before any library is loaded for the first time.
* Will throw an error after a library has been loaded for the first time.
*/
export function setOptions(options: APIOptions) {
if (isBootrapped_) {
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bootstrap is actually intended to gracefully ignore attempts to re-bootstrap (instead of throwing)
(see later note about keeping to similar design choices)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can surely do that as well.


options_ = options;
}

/**
* Import the specified library.
*/
export async function importLibrary(
...p: Parameters<typeof google.maps.importLibrary>
): ReturnType<typeof google.maps.importLibrary>;

export async function importLibrary<
TLibraryName extends APILibraryName,
TLibrary extends APILibraryMap[TLibraryName],
>(libraryName: TLibraryName): Promise<TLibrary> {
if (!isBootrapped_) {
bootstrapLoader(options_);
isBootrapped_ = true;
}

if (!libraries_[libraryName]) {
try {
const library = (await google.maps.importLibrary(
libraryName
)) as TLibrary;
libraries_[libraryName] = library;
} catch (error) {
if (error instanceof APILoadingError) {
isBootrapped_ = false;
throw new Error("The Google Maps JavaScript API failed to load.");
}

throw error;
}
}

return libraries_[libraryName] as TLibrary;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while I like adding better typing to importLibrary, that seems like something that could be better to fix directly in the google.maps typings.

also, from a usage perspective I'm wondering this should simply invoke the global google.maps.importLibrary - if there are two libraries that pin different (though compatible) versions of the bootstrap loader that might cause bootstrap to be triggered twice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the typings, there's already everything in place for @types/google.maps being updated at one point. I just didn't want to ship the v2-version with the incomplete types currently in @types/google.maps.

In situations where multiple versions of this package are somehow bundled in an Application, it's going to cause a problem, since the isBootstrapped_ variable is present twice as well (this will cause an error being thrown here: https://github.com/googlemaps/js-api-loader/pull/895/files#diff-debc445476274f687d8289e92416ea861546bc49990b42dc316f2a85faa5872eR36).

So it would probably be a better Idea to use presence of the google.maps.importLibrary function instead of the isBootstrapped_ variable.


const api = {
setOptions,
importLibrary,
};

export default api;
91 changes: 91 additions & 0 deletions src/2.0/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* NOTE: this is functionally equivalent to (and originally copied from) the
* official dynamic library import script:
* https://developers.google.com/maps/documentation/javascript/load-maps-js-api
*
* Formatting etc:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a really good job of de-minimizing!

but my concern with respect to these changes is keeping them in sync over time. preferably we could set up a pipeline from the google-internal version of this to GitHub that didn't require manual intervention.

we're looking into this internally

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also seeing more changes than just these (e.g. throwing instead of logging particular errors) - I think we should be careful to adhere to the same design choices though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. I don't think there will be situations where this loader and the one in the documentation have to handle things differently. I don't think there will be a lot of reasons for this to change at all (assuming the TrustedTypesPolicy could be implemented upstream).

There might also be a few details that slipped through, I remember there being some things that looked strange to me in the minified code... The custom error here is just part of an attempt to handle network-errors separately from other error-conditions.

* - removed IIFE parameters
* - formatted code, inlined/renamed variables and functions
* - fixed typescript compatibility
* - slightly restructured
* Functional Changes:
* - added support for TrustedTypes
* - add APILoadingError
*/

export type APIOptions = {
key?: string;
v?: string;
libraries?: string | string[];
language?: string;
region?: string;
authReferrerPolicy?: string;
mapIds?: string | string[];
channel?: string;
solutionChannel?: string;
};

export class APILoadingError extends Error {}

/**
* Creates the bootstrap function for the API loader. The bootstrap function is
* an initial version of `google.maps.importLibrary` that loads the actual JS
* which will then replace the bootstrap-function with the actual implementation.
*/
export function bootstrapLoader(options: APIOptions) {
if (google.maps.importLibrary)
throw new Error("bootstrapLoader can only be called once");

let apiLoadedPromise: Promise<void>;

// @ts-ignore
if (!window.google) window.google = {};
// @ts-ignore
if (!window.google.maps) window.google.maps = {};

const libraries = new Set();
const urlParameters = new URLSearchParams();

const startLoading = () => {
if (!apiLoadedPromise) {
apiLoadedPromise = new Promise((resolve, reject) => {
urlParameters.set("libraries", [...libraries] + "");
for (const [name, value] of Object.entries(options)) {
urlParameters.set(
name.replace(/[A-Z]/g, (t) => "_" + t[0].toLowerCase()),
value as string
);
}
urlParameters.set("callback", `google.maps.__ib__`);

const scriptEl = document.createElement("script");
scriptEl.src = `https://maps.googleapis.com/maps/api/js?${urlParameters.toString()}`;

scriptEl.onerror = () => {
reject(new APILoadingError());
};

const nonceScript =
document.querySelector<HTMLScriptElement>("script[nonce]");
scriptEl.nonce = nonceScript?.nonce || "";

// @ts-ignore
google.maps["__ib__"] = resolve;

document.head.append(scriptEl);
});
}

return apiLoadedPromise;
};

// create the intermediate importLibrary function that loads the API and calls
// the real importLibrary function.
google.maps.importLibrary = async (library: string) => {
libraries.add(library);

await startLoading();

return google.maps.importLibrary(library);
};
}
Loading