Skip to content

Commit

Permalink
feat: Global context shim (#23324)
Browse files Browse the repository at this point in the history
* chore: bootstrap @fluentui/global-context package

Autogenerated from `yarn create-package`

* fix codeowners

* update md

* feat: Global context shim

Adds a shim for `createContext` that will register the context to the
global scope, and reuse a globally scoped context if it already exists

* Add context selector shim and node + browser tests

* update md

* fix tsdoc

* add bundlesize

* Update packages/react-components/global-context/bundle-size/Default.fixture.js

Co-authored-by: Oleksandr Fediashov <[email protected]>

* custom webpack config

* Update packages/react-components/global-context/bundle-size/createContextSelector.fixture.js

Co-authored-by: Oleksandr Fediashov <[email protected]>

* add README

* remove react-theme

* remove griffel

Co-authored-by: Oleksandr Fediashov <[email protected]>
  • Loading branch information
ling1726 and layershifter authored Jun 1, 2022
1 parent e303ce6 commit 5090df2
Show file tree
Hide file tree
Showing 17 changed files with 329 additions and 7 deletions.
12 changes: 10 additions & 2 deletions packages/react-components/global-context/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @fluentui/global-context

**Global Context components for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)**
**Global Context for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)**

These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release.
This package contains a shim for `React.createContext` API that will register the context object to the global
scope (`window` for browsers, `global` for nodejs). This means that contexts will be real singletons.

> ⚠️ The recommended approach is not to use this package and deduplicate affected packages in node_modules
This package is is a workaround when multiple context objects are included into a bundle. This can happen when
there are multiple copies of the same package installed in `node_modules`.

**This package is not inteded to be used directly in code, but through a [Babel transform](/todo)**
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// @ts-check

module.exports = {
webpack: (/** @type {import('webpack').Configuration} */ config) => {
config.externals['@fluentui/react-context-selector'] = 'createContext';
return config;
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createContext } from '@fluentui/global-context';
console.log(createContext);

export default {
name: 'createContext',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createContextSelector } from '@fluentui/global-context';
console.log(createContextSelector);

export default {
name: 'createContextSelector',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createContext, createContextSelector } from '@fluentui/global-context';
import { SYMBOL_NAMESPACE } from '../src/global-context';
import { SYMBOL_NAMESPACE as CONTEXT_SELECTOR_SYMBOL_NAMESPACE } from '../src/global-context-selector';

function cleanWindowSymbols(namespace: string) {
getGlobalContextSymbols(namespace).forEach(sym => {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
delete window[sym];
});
}

function getWindowProperty(symbol: Symbol) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
return window[symbol];
}

function getGlobalContextSymbols(namespace: string) {
return Object.getOwnPropertySymbols(global).reduce((acc, cur) => {
if (Symbol.keyFor(cur)?.includes(namespace)) {
acc.push(cur);
}

return acc;
}, [] as Symbol[]);
}

describe('createContext', () => {
beforeEach(() => cleanWindowSymbols(SYMBOL_NAMESPACE));

it('should create context on window', () => {
const MyContext = createContext({}, 'MyContext', 'package', '9.0.0');
expect(getWindowProperty(getGlobalContextSymbols(SYMBOL_NAMESPACE)[0])).equals(MyContext);
});

it('should create single context', () => {
const MyContext = createContext({}, 'MyContext', 'package', '9.0.0');
const MyContext2 = createContext({}, 'MyContext', 'package', '9.0.0');

expect(getGlobalContextSymbols(SYMBOL_NAMESPACE).length).equals(1);
expect(getWindowProperty(getGlobalContextSymbols(SYMBOL_NAMESPACE)[0])).equals(MyContext);
expect(MyContext2).equals(MyContext);
});
});

describe('createContextSelector', () => {
beforeEach(() => cleanWindowSymbols(CONTEXT_SELECTOR_SYMBOL_NAMESPACE));

it('should create context on window', () => {
const MyContext = createContextSelector({}, 'MyContext', 'package', '9.0.0');
expect(getWindowProperty(getGlobalContextSymbols(CONTEXT_SELECTOR_SYMBOL_NAMESPACE)[0])).equals(MyContext);
});

it('should create single context', () => {
const MyContext = createContextSelector({}, 'MyContext', 'package', '9.0.0');
const MyContext2 = createContextSelector({}, 'MyContext', 'package', '9.0.0');

expect(getGlobalContextSymbols(CONTEXT_SELECTOR_SYMBOL_NAMESPACE).length).equals(1);
expect(getWindowProperty(getGlobalContextSymbols(CONTEXT_SELECTOR_SYMBOL_NAMESPACE)[0])).equals(MyContext);
expect(MyContext2).equals(MyContext);
});
});
9 changes: 9 additions & 0 deletions packages/react-components/global-context/e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"isolatedModules": false,
"types": ["node", "cypress", "cypress-storybook/cypress", "cypress-real-events"],
"lib": ["ES2019", "dom"]
},
"include": ["**/*.ts", "**/*.tsx"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
```ts

import * as React_2 from 'react';

// @public
export const createContext: <T>(defaultValue: T, name: string, packageName: string, packageVersion: string) => React_2.Context<T>;

// @public
export const createContextSelector: <T>(defaultValue: T, name: string, packageName: string, packageVersion: string) => React_2.Context<T>;

// (No @packageDocumentation comment for this package)

```
5 changes: 3 additions & 2 deletions packages/react-components/global-context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
"license": "MIT",
"scripts": {
"build": "just-scripts build",
"bundle-size": "bundle-size measure",
"clean": "just-scripts clean",
"code-style": "just-scripts code-style",
"e2e": "e2e",
"just": "just-scripts",
"lint": "just-scripts lint",
"start": "yarn storybook",
Expand All @@ -30,9 +32,8 @@
"@fluentui/scripts": "^1.0.0"
},
"dependencies": {
"@fluentui/react-theme": "9.0.0-rc.9",
"@fluentui/react-context-selector": "9.0.0-rc.10",
"@fluentui/react-utilities": "9.0.0-rc.10",
"@griffel/react": "1.1.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* This suite intentionally runs in node environment to simulate SSR
* @jest-environment node
*/

import { createContext, SYMBOL_NAMESPACE } from './global-context-selector';

function getGlobalProperty(symbol: Symbol) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
return global[symbol];
}

function getGlobalContextSymbols() {
return Object.getOwnPropertySymbols(global).reduce((acc, cur) => {
if (Symbol.keyFor(cur)?.includes(SYMBOL_NAMESPACE)) {
acc.push(cur);
}

return acc;
}, [] as Symbol[]);
}

describe('createContext', () => {
beforeEach(() => {
getGlobalContextSymbols().forEach(sym => {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
delete global[sym];
});
});

it('should create context on global', () => {
const MyContext = createContext({}, 'MyContext', 'package', '9.0.0');
const sym = getGlobalContextSymbols()[0];
expect(getGlobalProperty(sym)).toBe(MyContext);
});

it('should create single context', () => {
const MyContext = createContext({}, 'MyContext', 'package', '9.0.0');
const MyContext2 = createContext({}, 'MyContext', 'package', '9.0.0');

expect(getGlobalContextSymbols().length).toEqual(1);
expect(getGlobalProperty(getGlobalContextSymbols()[0])).toBe(MyContext);
expect(MyContext2).toBe(MyContext);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as React from 'react';
import { createContext as baseCreateContext } from '@fluentui/react-context-selector';
import { canUseDOM } from '@fluentui/react-utilities';
import { getMajorVersion } from './utils';
import { GlobalObject } from './types';

const isBrowser = canUseDOM();
const globalObject: GlobalObject = isBrowser ? window : global;

// Identifier for the symbol, for easy idenfitifaction of symbols created by this util
// Useful for clearning global object during SSR reloads
export const SYMBOL_NAMESPACE = 'global-context-selector:';

// During SSR the global object persists with the server process
// Clean out the global object during server reload during development
if (!isBrowser && process.env.NODE_ENV !== 'production') {
const globalSymbols = Object.getOwnPropertySymbols(globalObject);
globalSymbols.forEach(sym => {
if (Symbol.keyFor(sym)?.startsWith(SYMBOL_NAMESPACE)) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
delete globalObject[sym];
}
});
}

/**
* Wrapper around @see createContext from \@fluentui/react-context-selector that implements context registration
* in the globalThis object to avoid duplicate contexts. Contexts are keyed with
* a unique sybmol for the package name, version and name of the context.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol}
*
* @param defaultValue - @see React.createContext
* @param name - name of the context
* @param packageName - name of the npm package where the module is used
* @param packageVersion - version of the npm package where the module is used
* @returns @see React.createContext
*/
export const createContext = <T>(defaultValue: T, name: string, packageName: string, packageVersion: string) => {
// Symbol guaranteed to be unique for the entire runtime
const sym = Symbol.for(`${SYMBOL_NAMESPACE}${packageName}/${name}/@${getMajorVersion(packageVersion)}`);

// Objects keyed with symbols are not visible with console.log
// Object symbol properties can't be iterated with `for` or `Object.keys`
const globalSymbols = Object.getOwnPropertySymbols(globalObject);
if (!globalSymbols.includes(sym)) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
globalObject[sym] = baseCreateContext(defaultValue);
}

// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
return globalObject[sym] as React.Context<T>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* This suite intentionally runs in node environment to simulate SSR
* @jest-environment node
*/

import { createContext, SYMBOL_NAMESPACE } from './global-context';

function getGlobalProperty(symbol: Symbol) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
return global[symbol];
}

function getGlobalContextSymbols() {
return Object.getOwnPropertySymbols(global).reduce((acc, cur) => {
if (Symbol.keyFor(cur)?.includes(SYMBOL_NAMESPACE)) {
acc.push(cur);
}

return acc;
}, [] as Symbol[]);
}

describe('createContext', () => {
beforeEach(() => {
getGlobalContextSymbols().forEach(sym => {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
delete global[sym];
});
});

it('should create context on global', () => {
const MyContext = createContext({}, 'MyContext', 'package', '9.0.0');
const sym = getGlobalContextSymbols()[0];
expect(getGlobalProperty(sym)).toBe(MyContext);
});

it('should create single context', () => {
const MyContext = createContext({}, 'MyContext', 'package', '9.0.0');
const MyContext2 = createContext({}, 'MyContext', 'package', '9.0.0');

expect(getGlobalContextSymbols().length).toEqual(1);
expect(getGlobalProperty(getGlobalContextSymbols()[0])).toBe(MyContext);
expect(MyContext2).toBe(MyContext);
});
});
52 changes: 52 additions & 0 deletions packages/react-components/global-context/src/global-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import { canUseDOM } from '@fluentui/react-utilities';
import { GlobalObject } from './types';
import { getMajorVersion } from './utils';

const isBrowser = canUseDOM();
const globalObject: GlobalObject = isBrowser ? window : global;

// Identifier for the symbol, for easy idenfitifaction of symbols created by this util
// Useful for clearning global object during SSR reloads
export const SYMBOL_NAMESPACE = 'global-context:';

// During SSR the global object persists with the server process
// Clean out the global object during server reload during development
if (!isBrowser && process.env.NODE_ENV !== 'production') {
const globalSymbols = Object.getOwnPropertySymbols(globalObject);
globalSymbols.forEach(sym => {
if (Symbol.keyFor(sym)?.startsWith(SYMBOL_NAMESPACE)) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
delete globalObject[sym];
}
});
}

/**
* Wrapper around @see React.createContext that implements context registration
* in the globalThis object to avoid duplicate contexts. Contexts are keyed with
* a unique sybmol for the package name, version and name of the context.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol}
*
* @param defaultValue - @see React.createContext
* @param name - name of the context
* @param packageName - name of the npm package where the module is used
* @param packageVersion - version of the npm package where the module is used
* @returns @see React.createContext
*/
export const createContext = <T>(defaultValue: T, name: string, packageName: string, packageVersion: string) => {
// Symbol guaranteed to be unique for the entire runtime
const sym = Symbol.for(`${SYMBOL_NAMESPACE}${packageName}/${name}/@${getMajorVersion(packageVersion)}`);

// Objects keyed with symbols are not visible with console.log
// Object symbol properties can't be iterated with `for` or `Object.keys`
const globalSymbols = Object.getOwnPropertySymbols(globalObject);
if (!globalSymbols.includes(sym)) {
// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
globalObject[sym] = React.createContext(defaultValue);
}

// @ts-expect-error - Indexing object with symbols not supported until TS 4.4
return globalObject[sym] as React.Context<T>;
};
4 changes: 2 additions & 2 deletions packages/react-components/global-context/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// TODO: replace with real exports
export {};
export { createContext } from './global-context';
export { createContext as createContextSelector } from './global-context-selector';
3 changes: 3 additions & 0 deletions packages/react-components/global-context/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as React from 'react';

export type GlobalObject = (typeof globalThis | NodeJS.Global) & Record<symbol, React.Context<unknown>>;
10 changes: 10 additions & 0 deletions packages/react-components/global-context/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getMajorVersion } from './utils';
describe('getMajorVersion', () => {
it.each([
['9.0.0', 9],
['9.0.0-rc.1', 9],
['9.0.0-rc.1+buildNumber', 9],
])('should get major version number from %s', (version, majorVersion) => {
expect(getMajorVersion(version)).toBe(majorVersion);
});
});
7 changes: 7 additions & 0 deletions packages/react-components/global-context/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @param version - semver version string
* @returns The major version number
*/
export function getMajorVersion(version: string) {
return Number(version.split('.')[0]);
}
2 changes: 1 addition & 1 deletion packages/react-components/global-context/tsconfig.lib.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"declaration": true,
"declarationDir": "dist/types",
"inlineSources": true,
"types": ["static-assets", "environment"]
"types": ["static-assets", "environment", "node"]
},
"exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"],
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
Expand Down

0 comments on commit 5090df2

Please sign in to comment.