Skip to content
Open
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: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ module.exports = {
'EIP6963.test.ts',
'CAIP294.test.ts',
'initializeInpageProvider.test.ts',
'MetaMaskInpageProvider.test.ts',
'siteMetadata.test.ts',
'jest.setup.browser.js',
],
rules: {
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Prevent `document is not defined` error when constructing the provider in non-DOM environments, such as extension background pages and service workers ([#428](https://github.com/MetaMask/providers/pull/428))

## [22.1.1]

### Changed
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ const browserConfig = {
'**/*ExtensionProvider.test.ts',
'**/EIP6963.test.ts',
'**/CAIP294.test.ts',
'**/siteMetadata.test.ts',
],
setupFilesAfterEnv: ['./jest.setup.browser.js'],
};
Expand Down
57 changes: 57 additions & 0 deletions src/MetaMaskInpageProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
MetaMaskInpageProviderStreamName,
MetaMaskInpageProvider,
} from './MetaMaskInpageProvider';
import * as siteMetadata from './siteMetadata';
import { MockConnectionStream } from '../test/mocks/MockConnectionStream';

/**
Expand Down Expand Up @@ -1127,6 +1128,62 @@ describe('MetaMaskInpageProvider: Miscellanea', () => {
expect(inpageProvider.selectedAddress).toBe('0xabc');
expect(inpageProvider.isConnected()).toBe(true);
});

describe('site metadata', () => {
it('sends site metadata immediately if the document has already loaded', () => {
const sendSiteMetadataSpy = jest
.spyOn(siteMetadata, 'sendSiteMetadata')
.mockResolvedValue(undefined);

expect(
() =>
new MetaMaskInpageProvider(new MockConnectionStream(), {
shouldSendMetadata: true,
}),
).not.toThrow();

expect(sendSiteMetadataSpy).toHaveBeenCalledTimes(1);
});

it('sends site metadata on DOMContentLoaded if the document is still loading', () => {
const sendSiteMetadataSpy = jest
.spyOn(siteMetadata, 'sendSiteMetadata')
.mockResolvedValue(undefined);
jest
.spyOn(globalThis.document, 'readyState', 'get')
.mockReturnValue('loading');

expect(
() =>
new MetaMaskInpageProvider(new MockConnectionStream(), {
shouldSendMetadata: true,
}),
).not.toThrow();
expect(sendSiteMetadataSpy).not.toHaveBeenCalled();

globalThis.window.dispatchEvent(new Event('DOMContentLoaded'));
expect(sendSiteMetadataSpy).toHaveBeenCalledTimes(1);
});

it('sends site metadata immediately if there is no document, e.g. in an extension background script', () => {
const sendSiteMetadataSpy = jest
.spyOn(siteMetadata, 'sendSiteMetadata')
.mockResolvedValue(undefined);
const documentSpy = jest
.spyOn(globalThis, 'document', 'get')
.mockReturnValue(undefined as unknown as Document);

expect(
() =>
new MetaMaskInpageProvider(new MockConnectionStream(), {
shouldSendMetadata: true,
}),
).not.toThrow();
expect(sendSiteMetadataSpy).toHaveBeenCalledTimes(1);

documentSpy.mockRestore();
});
});
});

describe('isConnected', () => {
Expand Down
9 changes: 8 additions & 1 deletion src/MetaMaskInpageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,14 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider {

// send website metadata
if (shouldSendMetadata) {
if (document.readyState === 'complete') {
// The `document` global is not available in non-DOM environments, such
// as extension background pages and service workers. In such
// environments, there is nothing to wait for, so we treat the site
// metadata as immediately available.
if (
typeof document === 'undefined' ||
document.readyState === 'complete'
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendSiteMetadata(this._rpcEngine, this._log);
} else {
Expand Down
50 changes: 50 additions & 0 deletions src/siteMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { JsonRpcEngine } from '@metamask/json-rpc-engine';

import { sendSiteMetadata } from './siteMetadata';

describe('sendSiteMetadata', () => {
it('sends site metadata from the DOM', async () => {
const meta = globalThis.document.createElement('meta');
meta.setAttribute('property', 'og:site_name');
meta.content = 'Test Site';
globalThis.document.head.appendChild(meta);

const handle = jest.fn();
await sendSiteMetadata({ handle } as unknown as JsonRpcEngine, console);

expect(handle).toHaveBeenCalledTimes(1);
expect(handle).toHaveBeenCalledWith(
expect.objectContaining({
method: 'metamask_sendDomainMetadata',
params: {
name: 'Test Site',
icon: null,
},
}),
expect.any(Function),
);
});

it('falls back to the hostname if there is no document, e.g. in an extension background script', async () => {
const documentSpy = jest
.spyOn(globalThis, 'document', 'get')
.mockReturnValue(undefined as unknown as Document);

const handle = jest.fn();
await sendSiteMetadata({ handle } as unknown as JsonRpcEngine, console);

expect(handle).toHaveBeenCalledTimes(1);
expect(handle).toHaveBeenCalledWith(
expect.objectContaining({
method: 'metamask_sendDomainMetadata',
params: {
name: globalThis.location.hostname,
icon: null,
},
}),
expect.any(Function),
);

documentSpy.mockRestore();
});
});
20 changes: 15 additions & 5 deletions src/siteMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export async function sendSiteMetadata(
*/
async function getSiteMetadata() {
return {
name: getSiteName(window),
icon: await getSiteIcon(window),
name: getSiteName(globalThis),
icon: await getSiteIcon(globalThis),
};
}

Expand All @@ -52,8 +52,13 @@ async function getSiteMetadata() {
* @param windowObject - The window object to extract the site name from.
* @returns The site name.
*/
function getSiteName(windowObject: typeof window): string {
function getSiteName(windowObject: typeof globalThis): string {
const { document } = windowObject;
// The `document` global is not available in non-DOM environments, such as
// extension background pages and service workers.
if (typeof document === 'undefined') {
return windowObject.location.hostname;
}

const siteName: HTMLMetaElement | null = document.querySelector(
'head > meta[property="og:site_name"]',
Expand All @@ -73,7 +78,7 @@ function getSiteName(windowObject: typeof window): string {
return document.title;
}

return window.location.hostname;
return windowObject.location.hostname;
}

/**
Expand All @@ -83,9 +88,14 @@ function getSiteName(windowObject: typeof window): string {
* @returns An icon URL, if one exists.
*/
async function getSiteIcon(
windowObject: typeof window,
windowObject: typeof globalThis,
): Promise<string | null> {
const { document } = windowObject;
// The `document` global is not available in non-DOM environments, such as
// extension background pages and service workers.
if (typeof document === 'undefined') {
return null;
}

const icons: NodeListOf<HTMLLinkElement> = document.querySelectorAll(
'head > link[rel~="icon"]',
Expand Down