Merge pull request #7092 from backstage/rugvip/datawut

core-plugin-api: switch to using a plain string key for attaching component data
This commit is contained in:
Patrik Oldsberg
2021-09-09 16:41:45 +02:00
committed by GitHub
13 changed files with 117 additions and 348 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Removed deprecated internal functions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Migrated component data attachment method to have better compatibility with component proxies such as `react-hot-loader`.
@@ -1,118 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { attachComponentData, getComponentData } from './componentData';
describe('elementData', () => {
it('should attach a single piece of data', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should attach several distinct pieces of data', () => {
const data1 = { foo: 'bar' };
const data2 = { test: 'value' };
const Component = () => null;
attachComponentData(Component, 'my-data', data1);
attachComponentData(Component, 'second', data2);
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data1);
expect(getComponentData(element, 'second')).toBe(data2);
});
it('returns undefined for missing data', () => {
const data = { foo: 'bar' };
const Component1 = () => null;
const Component2 = () => null;
attachComponentData(Component2, 'my-data', data);
const element1 = <Component1 />;
const element2 = <Component2 />;
expect(getComponentData(element1, 'missing')).toBeUndefined();
expect(getComponentData(element2, 'missing')).toBeUndefined();
});
it('should throw when attempting to overwrite data', () => {
const data = { foo: 'bar' };
const MyComponent = () => null;
attachComponentData(MyComponent, 'my-data', data);
expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow(
'Attempted to attach duplicate data "my-data" to component "MyComponent"',
);
});
describe('works across versions', () => {
function getDataSymbol() {
const Component = () => null;
attachComponentData(Component, 'my-data', {});
const [symbol] = Object.getOwnPropertySymbols(Component);
return symbol;
}
it('should should be able to get data from older versions', () => {
const symbol = getDataSymbol();
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect((element as any).type[symbol].map.get('my-data')).toBe(data);
});
it('should should be able to attach data for older versions', () => {
const symbol = getDataSymbol();
const data = { foo: 'bar' };
const Component = () => null;
(Component as any)[symbol] = {
map: new Map([['my-data', data]]),
};
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should be able to get data from newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
const container = (global as any)[
'__@backstage/component-data-store__'
].get(element.type);
expect(container.map.get('my-data')).toBe(data);
});
it('should should be able to attach data for newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
(global as any)['__@backstage/component-data-store__'].set(Component, {
map: new Map([['my-data', data]]),
});
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
});
});
@@ -1,84 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentType, ReactNode } from 'react';
import { getOrCreateGlobalSingleton } from '../lib/globalObject';
// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x
const DATA_KEY = Symbol('backstage-component-data');
type ComponentWithData<P> = ComponentType<P> & {
[DATA_KEY]?: DataContainer;
};
type DataContainer = {
map: Map<string, unknown>;
};
type MaybeComponentNode = ReactNode & {
type?: ComponentType<any> & { [DATA_KEY]?: DataContainer };
};
// The store is bridged across versions using the global object
const store = getOrCreateGlobalSingleton(
'component-data-store',
() => new WeakMap<ComponentType<any>, DataContainer>(),
);
export function attachComponentData<P>(
component: ComponentType<P>,
type: string,
data: unknown,
) {
const dataComponent = component as ComponentWithData<P>;
let container = store.get(component) || dataComponent[DATA_KEY];
if (!container) {
container = { map: new Map() };
store.set(component, container);
dataComponent[DATA_KEY] = container;
}
if (container.map.has(type)) {
const name = component.displayName || component.name;
throw new Error(
`Attempted to attach duplicate data "${type}" to component "${name}"`,
);
}
container.map.set(type, data);
}
export function getComponentData<T>(
node: ReactNode,
type: string,
): T | undefined {
if (!node) {
return undefined;
}
const component = (node as MaybeComponentNode).type;
if (!component) {
return undefined;
}
const container = store.get(component) || component[DATA_KEY];
if (!container) {
return undefined;
}
return container.map.get(type) as T | undefined;
}
@@ -1,54 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Extension, RouteRef } from '@backstage/core-plugin-api';
type ComponentLoader<T> =
| {
lazy: () => Promise<T>;
}
| {
sync: T;
};
const ERROR_MESSAGE = 'Import this from @backstage/core-plugin-api';
/** @deprecated Import from @backstage/core-plugin-api instead */
export function createRoutableExtension<
T extends (props: any) => JSX.Element | null,
>(_options: {
component: () => Promise<T>;
mountPoint: RouteRef;
}): Extension<T> {
throw new Error(ERROR_MESSAGE);
}
/** @deprecated Import from @backstage/core-plugin-api instead */
export function createComponentExtension<
T extends (props: any) => JSX.Element | null,
>(_options: { component: ComponentLoader<T> }): Extension<T> {
throw new Error(ERROR_MESSAGE);
}
/** @deprecated Import from @backstage/core-plugin-api instead */
export function createReactExtension<
T extends (props: any) => JSX.Element | null,
>(_options: {
component: ComponentLoader<T>;
data?: Record<string, unknown>;
}): Extension<T> {
throw new Error(ERROR_MESSAGE);
}
@@ -1,22 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { attachComponentData, getComponentData } from './componentData';
export {
createReactExtension,
createRoutableExtension,
createComponentExtension,
} from './extensions';
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { getComponentData } from '../extensions';
import { BackstagePlugin, getComponentData } from '@backstage/core-plugin-api';
import { createCollector } from '../extensions/traversal';
export const pluginCollector = createCollector(
@@ -31,8 +31,8 @@ import {
createRouteRef,
createPlugin,
RouteRef,
attachComponentData,
} from '@backstage/core-plugin-api';
import { attachComponentData } from '../extensions';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
@@ -15,9 +15,8 @@
*/
import { isValidElement, ReactElement, ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { RouteRef, getComponentData } from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
@@ -15,7 +15,7 @@
*/
import React, { ComponentType } from 'react';
import { Button } from './Button';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api';
import {
Divider,
@@ -26,11 +26,7 @@ import {
Typography,
Button as MaterialButton,
} from '@material-ui/core';
// We don't want to export RoutingProvider from core-app-api, but it's way easier to
// use here. This hack only works in storybook stories.
// TODO: Export a nicer to user routing provider, perhaps from test-utils
// eslint-disable-next-line monorepo/no-internal-import
import { RoutingProvider } from '@backstage/core-app-api/src/routing/RoutingProvider';
import { wrapInTestApp } from '@backstage/test-utils';
const routeRef = createRouteRef({
id: 'storybook.test-route',
@@ -45,36 +41,29 @@ export default {
title: 'Inputs/Button',
component: Button,
decorators: [
(Story: ComponentType<{}>) => (
<>
<Typography>
A collection of buttons that should be used in the Backstage
interface. These leverage the properties inherited from{' '}
<Link href="https://material-ui.com/components/buttons/">
Material-UI Button
</Link>
, but include an opinionated set that align to the Backstage design.
</Typography>
(Story: ComponentType<{}>) =>
wrapInTestApp(
<>
<Typography>
A collection of buttons that should be used in the Backstage
interface. These leverage the properties inherited from{' '}
<Link href="https://material-ui.com/components/buttons/">
Material-UI Button
</Link>
, but include an opinionated set that align to the Backstage design.
</Typography>
<Divider />
<Divider />
<MemoryRouter>
<RoutingProvider
routeBindings={new Map()}
routeObjects={[]}
routeParents={new Map()}
routePaths={new Map([[routeRef, '/hello']])}
>
<div>
<div>
<div>
<Location />
</div>
<Story />
<Location />
</div>
</RoutingProvider>
</MemoryRouter>
</>
),
<Story />
</div>
</>,
{ mountedRoutes: { '/hello': routeRef } },
),
],
};
@@ -15,17 +15,9 @@
*/
import React, { ComponentType } from 'react';
import { Link } from './Link';
import {
MemoryRouter,
Route,
useLocation,
NavLink as RouterNavLink,
} from 'react-router-dom';
import { Route, useLocation, NavLink as RouterNavLink } from 'react-router-dom';
import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api';
// We don't want to export RoutingProvider from core-app-api, but it's way easier to
// use here. This hack only works in storybook stories.
// eslint-disable-next-line monorepo/no-internal-import
import { RoutingProvider } from '@backstage/core-app-api/src/routing/RoutingProvider';
import { wrapInTestApp } from '@backstage/test-utils';
const routeRef = createRouteRef({
id: 'storybook.test-route',
@@ -40,23 +32,16 @@ export default {
title: 'Navigation/Link',
component: Link,
decorators: [
(Story: ComponentType<{}>) => (
<MemoryRouter>
<RoutingProvider
routeBindings={new Map()}
routeObjects={[]}
routeParents={new Map()}
routePaths={new Map([[routeRef, '/hello']])}
>
(Story: ComponentType<{}>) =>
wrapInTestApp(
<div>
<div>
<div>
<Location />
</div>
<Story />
<Location />
</div>
</RoutingProvider>
</MemoryRouter>
),
<Story />
</div>,
{ mountedRoutes: { '/hello': routeRef } },
),
],
};
@@ -17,7 +17,7 @@
import React from 'react';
import { attachComponentData, getComponentData } from './componentData';
describe('elementData', () => {
describe('componentData', () => {
it('should attach a single piece of data', () => {
const data = { foo: 'bar' };
const Component = () => null;
@@ -59,4 +59,51 @@ describe('elementData', () => {
'Attempted to attach duplicate data "my-data" to component "MyComponent"',
);
});
describe('works across versions', () => {
it('should should be able to get data from newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
expect((element as any).type.__backstage_data.map.get('my-data')).toBe(
data,
);
});
it('should should be able to attach data for newer versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
(Component as any).__backstage_data = {
map: new Map([['my-data', data]]),
};
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
it('should be able to get data from older versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
attachComponentData(Component, 'my-data', data);
const element = <Component />;
const container = (global as any)[
'__@backstage/component-data-store__'
].get(element.type);
expect(container.map.get('my-data')).toBe(data);
});
it('should should be able to attach data for older versions', () => {
const data = { foo: 'bar' };
const Component = () => null;
(global as any)['__@backstage/component-data-store__'].set(Component, {
map: new Map([['my-data', data]]),
});
const element = <Component />;
expect(getComponentData(element, 'my-data')).toBe(data);
});
});
});
@@ -21,24 +21,42 @@ type DataContainer = {
map: Map<string, unknown>;
};
type MaybeComponentNode = ReactNode & {
type?: ComponentType<any>;
};
// The store is bridged across versions using the global object
// This method of storing the component data was deprecated in September 2021, it
// will be removed in the future for the reasons described below.
const globalStore = getOrCreateGlobalSingleton(
'component-data-store',
() => new WeakMap<ComponentType<any>, DataContainer>(),
);
// This key is used to attach component data to the component type (function or class)
// itself. This method is used because it has better compatibility component wrappers
// like react-hot-loader, as opposed to the WeakMap method or using a symbol.
const componentDataKey = '__backstage_data';
type ComponentWithData = ComponentType<any> & {
[componentDataKey]?: DataContainer;
};
type MaybeComponentNode = ReactNode & {
type?: ComponentWithData;
};
export function attachComponentData<P>(
component: ComponentType<P>,
type: string,
data: unknown,
) {
let container = globalStore.get(component);
const dataComponent = component as ComponentWithData;
let container = dataComponent[componentDataKey] ?? globalStore.get(component);
if (!container) {
container = { map: new Map() };
Object.defineProperty(dataComponent, componentDataKey, {
enumerable: false,
configurable: true,
writable: false,
value: container,
});
globalStore.set(component, container);
}
@@ -65,7 +83,7 @@ export function getComponentData<T>(
return undefined;
}
const container = globalStore.get(component);
const container = component[componentDataKey] ?? globalStore.get(component);
if (!container) {
return undefined;
}