From c7cdaf44a575445bb37a054665969ccd633c93eb Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 20 Dec 2024 00:47:15 +0100 Subject: [PATCH 1/8] feat: initial draft-implementation fot the 2.0 API # Conflicts: # package-lock.json --- package.json | 3 +- src/2.0/2.0.md | 63 +++++++++++++++++++++++++ src/2.0/index.ts | 108 ++++++++++++++++++++++++++++++++++++++++++ src/2.0/loader.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/2.0/2.0.md create mode 100644 src/2.0/index.ts create mode 100644 src/2.0/loader.ts diff --git a/package.json b/package.json index 8f0078cb..27b7bc28 100644 --- a/package.json +++ b/package.json @@ -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/*" }, @@ -44,6 +44,7 @@ "@types/google.maps": "^3.53.1", "@types/jest": "^29.5.5", "@types/selenium-webdriver": "^4.0.9", + "@types/trusted-types": "^2.0.7", "@typescript-eslint/eslint-plugin": "^8.8.1", "@typescript-eslint/parser": "^8.8.1", "core-js": "^3.6.4", diff --git a/src/2.0/2.0.md b/src/2.0/2.0.md new file mode 100644 index 00000000..369a2993 --- /dev/null +++ b/src/2.0/2.0.md @@ -0,0 +1,63 @@ +# 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"); +``` + +Since wrapping all code working with the maps API into async functions can be +problematic, we also provide an API that can be used in a synchronous context: + +```ts +import { + getImportedLibrary, + isLibraryImported, +} from "@googlemaps/js-api-loader"; + +try { + // getImportedLibrary throws an Error when the library hasn't been loaded yet + // (otherwise the destructuring of the result wouldn't work) + const { Map } = getImportedLibrary("maps"); +} catch (err) {} + +// when guarded by isLibraryImported, it's guaranteed to not throw +if (isLibraryImported("maps")) { + const { Map } = importLibrarySync("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 throw an Error + + - 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 diff --git a/src/2.0/index.ts b/src/2.0/index.ts new file mode 100644 index 00000000..2b1a5180 --- /dev/null +++ b/src/2.0/index.ts @@ -0,0 +1,108 @@ +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 = {}; + +/** + * 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_) { + throw new Error( + "options cannot be modified after the API has been loaded." + ); + } + + options_ = options; +} + +/** + * Import the specified library. + */ +export async function importLibrary( + ...p: Parameters +): ReturnType; + +export async function importLibrary< + TLibraryName extends keyof ApiLibraryMap, + TLibrary extends ApiLibraryMap[TLibraryName], +>(libraryName: TLibraryName): Promise { + 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; +} + +/** + * Synchronously loads a library. Will directly return the library, or null + * if it hasn't been loaded. + */ +export function getImportedLibrary< + TLibraryName extends ApiLibraryName, + TLibrary extends ApiLibraryMap[TLibraryName], +>(libraryName: TLibraryName): TLibrary | null { + if (!isLibraryImported(libraryName)) { + throw new Error(`library ${libraryName} hasn't been imported.`); + } + + return libraries_[libraryName] as TLibrary; +} + +/** + * Check if the given library has already been loaded. + */ +export function isLibraryImported(libraryName: ApiLibraryName): boolean { + return libraryName in libraries_; +} + +const api = { + setOptions, + importLibrary, + getImportedLibrary, + isLibraryImported, +}; + +export default api; diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts new file mode 100644 index 00000000..160f7cb0 --- /dev/null +++ b/src/2.0/loader.ts @@ -0,0 +1,116 @@ +/* + * 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: + * - 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; + language?: string; + region?: string; + authReferrerPolicy?: string; + mapIds?: 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; + + // @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 + ); + } + urlParameters.set("callback", `google.maps.__ib__`); + + const scriptEl = document.createElement("script"); + scriptEl.src = getTrustedScriptUrl( + `https://maps.googleapis.com/maps/api/js?${urlParameters.toString()}` + ) as string; + + scriptEl.onerror = () => { + reject(new ApiLoadingError()); + }; + + const nonceScript = + document.querySelector("script[nonce]"); + scriptEl.nonce = nonceScript?.nonce || ""; + + // @ts-ignore + google.maps["__ib__"] = resolve; + + document.head.append(scriptEl); + }); + } + + return apiLoadedPromise; + }; + + // create 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); + }; +} + +const getTrustedScriptUrl: (url: string) => string | TrustedScriptURL = (() => { + // check if trustedTypes are supported + if (typeof window === "undefined" || !("trustedTypes" in window)) { + return (url) => url; + } + + // this policy will only certify the `maps.googleapis.com` script url, + // everything else will throw an error. + const policy = window.trustedTypes.createPolicy( + "google-maps-api#js-api-loader", + { + createScriptURL: (input) => { + const url = new URL(input); + if (url.host !== "maps.googleapis.com") throw new Error("invalid url"); + + return input; + }, + } + ); + + return (url) => policy.createScriptURL(url); +})(); From ecc641fea437a89783a595286edff3db67f4e3b0 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 3 Jan 2025 11:12:17 +0100 Subject: [PATCH 2/8] fix: use consistent uppercase for acronyms --- src/2.0/index.ts | 24 ++++++++++++------------ src/2.0/loader.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/2.0/index.ts b/src/2.0/index.ts index 2b1a5180..44c23a2d 100644 --- a/src/2.0/index.ts +++ b/src/2.0/index.ts @@ -1,10 +1,10 @@ -import { ApiLoadingError, type ApiOptions, bootstrapLoader } from "./loader"; +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 { +interface APILibraryMap { core: google.maps.CoreLibrary; drawing: google.maps.DrawingLibrary; elevation: google.maps.ElevationLibrary; @@ -20,18 +20,18 @@ interface ApiLibraryMap { visualization: google.maps.VisualizationLibrary; } -type ApiLibraryName = keyof ApiLibraryMap; +type APILibraryName = keyof APILibraryMap; let isBootrapped_ = false; -let options_: ApiOptions = {}; -const libraries_: Partial = {}; +let options_: APIOptions = {}; +const libraries_: Partial = {}; /** * 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) { +export function setOptions(options: APIOptions) { if (isBootrapped_) { throw new Error( "options cannot be modified after the API has been loaded." @@ -49,8 +49,8 @@ export async function importLibrary( ): ReturnType; export async function importLibrary< - TLibraryName extends keyof ApiLibraryMap, - TLibrary extends ApiLibraryMap[TLibraryName], + TLibraryName extends keyof APILibraryMap, + TLibrary extends APILibraryMap[TLibraryName], >(libraryName: TLibraryName): Promise { if (!isBootrapped_) { bootstrapLoader(options_); @@ -64,7 +64,7 @@ export async function importLibrary< )) as TLibrary; libraries_[libraryName] = library; } catch (error) { - if (error instanceof ApiLoadingError) { + if (error instanceof APILoadingError) { isBootrapped_ = false; throw new Error("The Google Maps JavaScript API failed to load."); } @@ -81,8 +81,8 @@ export async function importLibrary< * if it hasn't been loaded. */ export function getImportedLibrary< - TLibraryName extends ApiLibraryName, - TLibrary extends ApiLibraryMap[TLibraryName], + TLibraryName extends APILibraryName, + TLibrary extends APILibraryMap[TLibraryName], >(libraryName: TLibraryName): TLibrary | null { if (!isLibraryImported(libraryName)) { throw new Error(`library ${libraryName} hasn't been imported.`); @@ -94,7 +94,7 @@ export function getImportedLibrary< /** * Check if the given library has already been loaded. */ -export function isLibraryImported(libraryName: ApiLibraryName): boolean { +export function isLibraryImported(libraryName: APILibraryName): boolean { return libraryName in libraries_; } diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts index 160f7cb0..5a0ec6a1 100644 --- a/src/2.0/loader.ts +++ b/src/2.0/loader.ts @@ -13,7 +13,7 @@ * - add APILoadingError */ -export type ApiOptions = { +export type APIOptions = { key?: string; v?: string; libraries?: string; @@ -25,14 +25,14 @@ export type ApiOptions = { solutionChannel?: string; }; -export class ApiLoadingError extends Error {} +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) { +export function bootstrapLoader(options: APIOptions) { if (google.maps.importLibrary) throw new Error("bootstrapLoader can only be called once"); @@ -59,12 +59,12 @@ export function bootstrapLoader(options: ApiOptions) { urlParameters.set("callback", `google.maps.__ib__`); const scriptEl = document.createElement("script"); - scriptEl.src = getTrustedScriptUrl( + scriptEl.src = getTrustedScriptURL( `https://maps.googleapis.com/maps/api/js?${urlParameters.toString()}` ) as string; scriptEl.onerror = () => { - reject(new ApiLoadingError()); + reject(new APILoadingError()); }; const nonceScript = @@ -92,7 +92,7 @@ export function bootstrapLoader(options: ApiOptions) { }; } -const getTrustedScriptUrl: (url: string) => string | TrustedScriptURL = (() => { +const getTrustedScriptURL: (url: string) => string | TrustedScriptURL = (() => { // check if trustedTypes are supported if (typeof window === "undefined" || !("trustedTypes" in window)) { return (url) => url; From 1569ca3c09bdc44f9f37370393b894808c4a6977 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 3 Jan 2025 11:12:31 +0100 Subject: [PATCH 3/8] fix: typo --- src/2.0/2.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/2.0/2.0.md b/src/2.0/2.0.md index 369a2993..94f07011 100644 --- a/src/2.0/2.0.md +++ b/src/2.0/2.0.md @@ -39,7 +39,7 @@ try { // when guarded by isLibraryImported, it's guaranteed to not throw if (isLibraryImported("maps")) { - const { Map } = importLibrarySync("maps"); + const { Map } = getImportedLibrary("maps"); } ``` From 067ad789379ce7f36d55898a5fa5c402050a0ca7 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 3 Jan 2025 11:13:00 +0100 Subject: [PATCH 4/8] fix: mapIds can be a string[] as well --- src/2.0/loader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts index 5a0ec6a1..c116dbac 100644 --- a/src/2.0/loader.ts +++ b/src/2.0/loader.ts @@ -20,7 +20,7 @@ export type APIOptions = { language?: string; region?: string; authReferrerPolicy?: string; - mapIds?: string; + mapIds?: string | string[]; channel?: string; solutionChannel?: string; }; @@ -53,7 +53,7 @@ export function bootstrapLoader(options: APIOptions) { for (const [name, value] of Object.entries(options)) { urlParameters.set( name.replace(/[A-Z]/g, (t) => "_" + t[0].toLowerCase()), - value + value as string ); } urlParameters.set("callback", `google.maps.__ib__`); From 9c59d6aa1e2aa391e1ef88bb6b880ea63b57708a Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Fri, 3 Jan 2025 11:35:55 +0100 Subject: [PATCH 5/8] fix: libraries can be a string[] --- src/2.0/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts index c116dbac..1dd30ade 100644 --- a/src/2.0/loader.ts +++ b/src/2.0/loader.ts @@ -16,7 +16,7 @@ export type APIOptions = { key?: string; v?: string; - libraries?: string; + libraries?: string | string[]; language?: string; region?: string; authReferrerPolicy?: string; From a5e4e16bf49d7c29fc2cfff8b0fa14fb8cf1b739 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 13 Jan 2025 15:42:14 +0100 Subject: [PATCH 6/8] docs: update readme --- src/2.0/2.0.md | 112 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 95 insertions(+), 17 deletions(-) diff --git a/src/2.0/2.0.md b/src/2.0/2.0.md index 94f07011..0d38aca3 100644 --- a/src/2.0/2.0.md +++ b/src/2.0/2.0.md @@ -22,24 +22,102 @@ import { setOptions, importLibrary } from "@googlemaps/js-api-loader"; const { Map } = await importLibrary("maps"); ``` -Since wrapping all code working with the maps API into async functions can be -problematic, we also provide an API that can be used in a synchronous context: +## Synchronous API (TBD) -```ts -import { - getImportedLibrary, - isLibraryImported, -} from "@googlemaps/js-api-loader"; - -try { - // getImportedLibrary throws an Error when the library hasn't been loaded yet - // (otherwise the destructuring of the result wouldn't work) - const { Map } = getImportedLibrary("maps"); -} catch (err) {} - -// when guarded by isLibraryImported, it's guaranteed to not throw -if (isLibraryImported("maps")) { - const { Map } = getImportedLibrary("maps"); +### Motivation + +There are a lot of situations where the `importLibrary` function doesn't +work well, since using an async function or promises isn't always a viable +option. + +Currently, the only alternative to `importLibrary` is to use the global +`google.maps` namespaces. An additional synchronous API is intended to +provide an alternative way to using the global namespaces while solving some +of the problems that come with using them. + +Any synchronous access to the libraries requires developers to make sure the +libraries have already been loaded when the corresponding code is executed. +In practice, this is rarely a big issue. + +The exact shape of the synchronous API is to be determined, it could be a +simple Map instance `libraries` or a pair of has/get functions to check for and +retrieve loaded libraries. + +### Example 1: helper classes + +Imagine some service class that uses the `places` library and the +`PlacesService` to do it's thing. + +#### global namespace + +This is how it would be written with the global `google.maps` namespace: + +```tsx +class PlacesHelper { + private service: google.maps.places.PlacesService; + + constructor() { + if (!google.maps.places.PlacesService) + throw new Error("maps api or places library missing"); + + this.service = new google.maps.places.PlacesService(); + } + + // ... +} +``` + +This has two drawbacks: + +- having to write out `google.maps.places` for all classes (and + types, but that's a seperate issue) adds a lot of "noise" to the code +- references to the global namespace can't really be minified, and + due to the late loading of the API, a global assignment to a shorthand + name isn't really possible. + +#### importLibrary + +Since in a constructor, we can't `await` the result of `importLibrary`, the +only way to do this is using the `.then()` function, which drastically +changes the semantics of the code: + +```tsx +class PlacesHelper { + private service: google.maps.places.PlacesService | null = null; + + constructor() { + importLibrary("places").then( + ({ PlacesService }) => (this.service = new PlacesService()) + ); + } +} +``` + +Here, the service has to be declared as optional (`| null`) in typescript, +and every other method of the class has to somehow deal with the fact that +the service might not yet have been initialized. Even if the library is +already loaded, it won't be returned until the queued mircotasks and the +handlers for the awaited Promise are executed. +This can even have cascading effects on all classes calling methods of this +class. + +#### proposed sync API + +A synchronous API would allow us to write the same code we used for +global namespaces, but without the readability problems and without global +namespaces: + +```tsx +class PlacesHelper { + private service: google.maps.places.PlacesService = null; + + constructor() { + if (!isLibraryImported("places")) + throw new Error("maps api or places library missing"); + + const { PlacesService } = getImportedLibrary("places"); + this.service = new PlacesService(); + } } ``` From 1ad96c4427164f3359ac8c561a32a5b11c019e95 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Thu, 26 Jun 2025 23:18:50 +0200 Subject: [PATCH 7/8] fix: remove features not supported upstream --- package.json | 1 - src/2.0/2.0.md | 101 +--------------------------------------------- src/2.0/index.ts | 30 +------------- src/2.0/loader.ts | 29 +------------ 4 files changed, 5 insertions(+), 156 deletions(-) diff --git a/package.json b/package.json index 27b7bc28..2121e666 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@types/google.maps": "^3.53.1", "@types/jest": "^29.5.5", "@types/selenium-webdriver": "^4.0.9", - "@types/trusted-types": "^2.0.7", "@typescript-eslint/eslint-plugin": "^8.8.1", "@typescript-eslint/parser": "^8.8.1", "core-js": "^3.6.4", diff --git a/src/2.0/2.0.md b/src/2.0/2.0.md index 0d38aca3..ff7ec405 100644 --- a/src/2.0/2.0.md +++ b/src/2.0/2.0.md @@ -22,105 +22,6 @@ import { setOptions, importLibrary } from "@googlemaps/js-api-loader"; const { Map } = await importLibrary("maps"); ``` -## Synchronous API (TBD) - -### Motivation - -There are a lot of situations where the `importLibrary` function doesn't -work well, since using an async function or promises isn't always a viable -option. - -Currently, the only alternative to `importLibrary` is to use the global -`google.maps` namespaces. An additional synchronous API is intended to -provide an alternative way to using the global namespaces while solving some -of the problems that come with using them. - -Any synchronous access to the libraries requires developers to make sure the -libraries have already been loaded when the corresponding code is executed. -In practice, this is rarely a big issue. - -The exact shape of the synchronous API is to be determined, it could be a -simple Map instance `libraries` or a pair of has/get functions to check for and -retrieve loaded libraries. - -### Example 1: helper classes - -Imagine some service class that uses the `places` library and the -`PlacesService` to do it's thing. - -#### global namespace - -This is how it would be written with the global `google.maps` namespace: - -```tsx -class PlacesHelper { - private service: google.maps.places.PlacesService; - - constructor() { - if (!google.maps.places.PlacesService) - throw new Error("maps api or places library missing"); - - this.service = new google.maps.places.PlacesService(); - } - - // ... -} -``` - -This has two drawbacks: - -- having to write out `google.maps.places` for all classes (and - types, but that's a seperate issue) adds a lot of "noise" to the code -- references to the global namespace can't really be minified, and - due to the late loading of the API, a global assignment to a shorthand - name isn't really possible. - -#### importLibrary - -Since in a constructor, we can't `await` the result of `importLibrary`, the -only way to do this is using the `.then()` function, which drastically -changes the semantics of the code: - -```tsx -class PlacesHelper { - private service: google.maps.places.PlacesService | null = null; - - constructor() { - importLibrary("places").then( - ({ PlacesService }) => (this.service = new PlacesService()) - ); - } -} -``` - -Here, the service has to be declared as optional (`| null`) in typescript, -and every other method of the class has to somehow deal with the fact that -the service might not yet have been initialized. Even if the library is -already loaded, it won't be returned until the queued mircotasks and the -handlers for the awaited Promise are executed. -This can even have cascading effects on all classes calling methods of this -class. - -#### proposed sync API - -A synchronous API would allow us to write the same code we used for -global namespaces, but without the readability problems and without global -namespaces: - -```tsx -class PlacesHelper { - private service: google.maps.places.PlacesService = null; - - constructor() { - if (!isLibraryImported("places")) - throw new Error("maps api or places library missing"); - - const { PlacesService } = getImportedLibrary("places"); - this.service = new PlacesService(); - } -} -``` - ## Internal Behavior - the ApiLoader doesn't do anything (except for storing the options) until @@ -129,7 +30,7 @@ class PlacesHelper { 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 throw an Error + 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 diff --git a/src/2.0/index.ts b/src/2.0/index.ts index 44c23a2d..5f866d84 100644 --- a/src/2.0/index.ts +++ b/src/2.0/index.ts @@ -33,9 +33,7 @@ const libraries_: Partial = {}; */ export function setOptions(options: APIOptions) { if (isBootrapped_) { - throw new Error( - "options cannot be modified after the API has been loaded." - ); + return; } options_ = options; @@ -49,7 +47,7 @@ export async function importLibrary( ): ReturnType; export async function importLibrary< - TLibraryName extends keyof APILibraryMap, + TLibraryName extends APILibraryName, TLibrary extends APILibraryMap[TLibraryName], >(libraryName: TLibraryName): Promise { if (!isBootrapped_) { @@ -76,33 +74,9 @@ export async function importLibrary< return libraries_[libraryName] as TLibrary; } -/** - * Synchronously loads a library. Will directly return the library, or null - * if it hasn't been loaded. - */ -export function getImportedLibrary< - TLibraryName extends APILibraryName, - TLibrary extends APILibraryMap[TLibraryName], ->(libraryName: TLibraryName): TLibrary | null { - if (!isLibraryImported(libraryName)) { - throw new Error(`library ${libraryName} hasn't been imported.`); - } - - return libraries_[libraryName] as TLibrary; -} - -/** - * Check if the given library has already been loaded. - */ -export function isLibraryImported(libraryName: APILibraryName): boolean { - return libraryName in libraries_; -} - const api = { setOptions, importLibrary, - getImportedLibrary, - isLibraryImported, }; export default api; diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts index 1dd30ade..0a56b3e2 100644 --- a/src/2.0/loader.ts +++ b/src/2.0/loader.ts @@ -59,9 +59,7 @@ export function bootstrapLoader(options: APIOptions) { urlParameters.set("callback", `google.maps.__ib__`); const scriptEl = document.createElement("script"); - scriptEl.src = getTrustedScriptURL( - `https://maps.googleapis.com/maps/api/js?${urlParameters.toString()}` - ) as string; + scriptEl.src = `https://maps.googleapis.com/maps/api/js?${urlParameters.toString()}`; scriptEl.onerror = () => { reject(new APILoadingError()); @@ -81,7 +79,7 @@ export function bootstrapLoader(options: APIOptions) { return apiLoadedPromise; }; - // create intermediate importLibrary function that loads the API and calls + // create the intermediate importLibrary function that loads the API and calls // the real importLibrary function. google.maps.importLibrary = async (library: string) => { libraries.add(library); @@ -91,26 +89,3 @@ export function bootstrapLoader(options: APIOptions) { return google.maps.importLibrary(library); }; } - -const getTrustedScriptURL: (url: string) => string | TrustedScriptURL = (() => { - // check if trustedTypes are supported - if (typeof window === "undefined" || !("trustedTypes" in window)) { - return (url) => url; - } - - // this policy will only certify the `maps.googleapis.com` script url, - // everything else will throw an error. - const policy = window.trustedTypes.createPolicy( - "google-maps-api#js-api-loader", - { - createScriptURL: (input) => { - const url = new URL(input); - if (url.host !== "maps.googleapis.com") throw new Error("invalid url"); - - return input; - }, - } - ); - - return (url) => policy.createScriptURL(url); -})(); From ef1afbf0e38bfa184da3e66eceeee3a700209148 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 30 Jun 2025 09:31:48 +0200 Subject: [PATCH 8/8] refactor: return instead of else --- src/2.0/loader.ts | 56 ++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts index 0a56b3e2..42305813 100644 --- a/src/2.0/loader.ts +++ b/src/2.0/loader.ts @@ -47,35 +47,37 @@ export function bootstrapLoader(options: APIOptions) { 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("script[nonce]"); - scriptEl.nonce = nonceScript?.nonce || ""; - - // @ts-ignore - google.maps["__ib__"] = resolve; - - document.head.append(scriptEl); - }); + if (apiLoadedPromise) { + return 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("script[nonce]"); + scriptEl.nonce = nonceScript?.nonce || ""; + + // @ts-ignore + google.maps["__ib__"] = resolve; + + document.head.append(scriptEl); + }); + return apiLoadedPromise; };