;
+};
+
+type ComponentWithData = ComponentType
& {
+ [DATA_KEY]?: DataContainer;
+};
+
+type ReactNodeWithData = ReactNode & {
+ type?: { [DATA_KEY]?: DataContainer };
+};
+
+export function attachComponentData
(
+ component: ComponentType
,
+ type: string,
+ data: unknown,
+) {
+ const dataComponent = component as ComponentWithData
;
+
+ let container = dataComponent[DATA_KEY];
+ if (!container) {
+ container = dataComponent[DATA_KEY] = { map: new Map() };
+ }
+
+ 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(
+ node: ReactNode,
+ type: string,
+): T | undefined {
+ if (!node) {
+ return undefined;
+ }
+
+ const container = (node as ReactNodeWithData).type?.[DATA_KEY];
+ if (!container) {
+ return undefined;
+ }
+
+ return container.map.get(type) as T | undefined;
+}
diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx
new file mode 100644
index 0000000000..92fcecebc4
--- /dev/null
+++ b/packages/core-api/src/extensions/extensions.test.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { createPlugin } from '../plugin';
+import { createRouteRef } from '../routing';
+import { getComponentData } from './componentData';
+import {
+ createComponentExtension,
+ createReactExtension,
+ createRoutableExtension,
+} from './extensions';
+
+const plugin = createPlugin({
+ id: 'my-plugin',
+});
+
+describe('extensions', () => {
+ it('should create a react extension with component data', () => {
+ const Component = () => null;
+
+ const extension = createReactExtension({
+ component: Component,
+ data: {
+ myData: { foo: 'bar' },
+ },
+ });
+
+ const ExtensionComponent = plugin.provide(extension);
+ const element = ;
+
+ expect(getComponentData(element, 'core.plugin')).toBe(plugin);
+ expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' });
+ });
+
+ it('should create react extensions of different types', () => {
+ const Component = () => null;
+ const routeRef = createRouteRef({ path: '/foo', title: 'Foo' });
+
+ const extension1 = createComponentExtension({
+ component: Component,
+ });
+
+ const extension2 = createRoutableExtension({
+ component: Component,
+ mountPoint: routeRef,
+ });
+
+ const ExtensionComponent1 = plugin.provide(extension1);
+ const ExtensionComponent2 = plugin.provide(extension2);
+
+ const element1 = ;
+ const element2 = ;
+
+ expect(getComponentData(element1, 'core.plugin')).toBe(plugin);
+ expect(getComponentData(element2, 'core.plugin')).toBe(plugin);
+ expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef);
+ });
+});
diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx
new file mode 100644
index 0000000000..4087d5f963
--- /dev/null
+++ b/packages/core-api/src/extensions/extensions.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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, {
+ NamedExoticComponent,
+ ComponentType,
+ PropsWithChildren,
+} from 'react';
+import { RouteRef } from '../routing';
+import { attachComponentData } from './componentData';
+import { Extension, BackstagePlugin } from '../plugin/types';
+
+export function createRoutableExtension(options: {
+ component: ComponentType;
+ mountPoint: RouteRef;
+ // TODO(Rugvip): We want to carry forward the exact props type from the inner component, with
+ // or without children. ComponentType stops us from doing that though, as it always
+ // adds children to the props internally. We may want to work around this with custom types.
+}): Extension<
+ NamedExoticComponent>
+> {
+ const { component, mountPoint } = options;
+ return createReactExtension({
+ component,
+ data: {
+ 'core.mountPoint': mountPoint,
+ },
+ });
+}
+
+export function createComponentExtension(options: {
+ component: ComponentType;
+}): Extension> {
+ const { component } = options;
+ return createReactExtension({ component });
+}
+
+export function createReactExtension(options: {
+ component: ComponentType;
+ data?: Record;
+}): Extension> {
+ const { component: Component, data = {} } = options;
+ return {
+ expose(plugin: BackstagePlugin): NamedExoticComponent {
+ const Result = (props: Props) => ;
+
+ attachComponentData(Result, 'core.plugin', plugin);
+ for (const [key, value] of Object.entries(data)) {
+ attachComponentData(Result, key, value);
+ }
+
+ const name = Component.displayName || Component.name || 'Component';
+ if (name) {
+ Result.displayName = `Extension(${name})`;
+ }
+ return Result as NamedExoticComponent;
+ },
+ };
+}
diff --git a/packages/core-api/src/extensions/index.ts b/packages/core-api/src/extensions/index.ts
new file mode 100644
index 0000000000..26a0c597b1
--- /dev/null
+++ b/packages/core-api/src/extensions/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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';
diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx
new file mode 100644
index 0000000000..a69ee9de77
--- /dev/null
+++ b/packages/core-api/src/extensions/traversal.test.tsx
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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, { Children, isValidElement } from 'react';
+import {
+ childDiscoverer,
+ createCollector,
+ traverseElementTree,
+} from './traversal';
+
+describe('discovery', () => {
+ it('should collect element names', () => {
+ const root = (
+
+
+
+
+
Title
+ Text
+
+
+ );
+
+ const { names } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer],
+ collectors: {
+ names: createCollector(Array(), (acc, el) => {
+ if (typeof el.type === 'string') {
+ acc.push(el.type);
+ }
+ }),
+ },
+ });
+
+ expect(names).toEqual([
+ 'main',
+ 'div',
+ 'hr',
+ 'div',
+ 'h1',
+ 'p',
+ 'h2',
+ 'span',
+ ]);
+ });
+
+ it('should collect element names while skipping one level of children', () => {
+ const root = (
+
+
+
+
+
Title
+ Text
+
+
+ );
+
+ const { names } = traverseElementTree({
+ root,
+ discoverers: [
+ el =>
+ Children.toArray(el.props.children).flatMap(child =>
+ isValidElement(child) ? child?.props?.children : [],
+ ),
+ ],
+ collectors: {
+ names: createCollector(Array(), (acc, el) => {
+ if (typeof el.type === 'string') {
+ acc.push(el.type);
+ }
+ }),
+ },
+ });
+
+ expect(names).toEqual(['main', 'h1', 'p', 'h2', 'span']);
+ });
+});
diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts
new file mode 100644
index 0000000000..a1fbdab25a
--- /dev/null
+++ b/packages/core-api/src/extensions/traversal.ts
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { isValidElement, ReactNode, ReactElement, Children } from 'react';
+
+export type Discoverer = (element: ReactElement) => ReactNode;
+
+export type Collector = () => {
+ accumulator: Result;
+ visit(
+ accumulator: Result,
+ element: ReactElement,
+ parent: ReactElement,
+ context: Context,
+ ): Context;
+};
+
+/**
+ * A function that allows you to traverse a tree of React elements using
+ * varying methods to discover child nodes and collect data along the way.
+ */
+export function traverseElementTree(options: {
+ root: ReactElement;
+ discoverers: Discoverer[];
+ collectors: { [name in keyof Results]: Collector };
+}): Results {
+ const visited = new Set();
+ const collectors: {
+ [name in string]: ReturnType>;
+ } = {};
+
+ // Bootstrap all collectors, initializing the accumulators and providing the visitor function
+ for (const name in options.collectors) {
+ if (options.collectors.hasOwnProperty(name)) {
+ collectors[name] = options.collectors[name]();
+ }
+ }
+
+ // Internal representation of an element in the tree that we're iterating over
+ type QueueItem = {
+ node: ReactNode;
+ parent: ReactElement;
+ contexts: { [name in string]: unknown };
+ };
+
+ const queue = [
+ {
+ node: Children.toArray(options.root),
+ parent: options.root,
+ contexts: {},
+ } as QueueItem,
+ ];
+
+ while (queue.length !== 0) {
+ const { node, parent, contexts } = queue.shift()!;
+
+ // While the parent and the element we pass on to collectors and discoverers
+ // have been validated and are known to be React elements, the child nodes
+ // emitted by the discoverers are not.
+ Children.forEach(node, element => {
+ if (!isValidElement(element)) {
+ return;
+ }
+ if (visited.has(element)) {
+ const anyType = element?.type as
+ | { displayName?: string; name?: string }
+ | undefined;
+ const name = anyType?.displayName || anyType?.name || String(anyType);
+ throw new Error(`Visited element ${name} twice`);
+ }
+ visited.add(element);
+
+ const nextContexts: QueueItem['contexts'] = {};
+
+ // Collectors populate their result data using the current node, and compute
+ // context for the next iteration
+ for (const name in collectors) {
+ if (collectors.hasOwnProperty(name)) {
+ const collector = collectors[name];
+
+ nextContexts[name] = collector.visit(
+ collector.accumulator,
+ element,
+ parent,
+ contexts[name],
+ );
+ }
+ }
+
+ // Discoverers provide ways to continue the traversal from the current element
+ for (const discoverer of options.discoverers) {
+ const children = discoverer(element);
+ if (children) {
+ queue.push({
+ node: children,
+ parent: element,
+ contexts: nextContexts,
+ });
+ }
+ }
+ });
+ }
+
+ return Object.fromEntries(
+ Object.entries(collectors).map(([name, c]) => [name, c.accumulator]),
+ ) as Results;
+}
+
+export function createCollector(
+ initialResult: Result,
+ visit: ReturnType>['visit'],
+): Collector {
+ return () => ({ accumulator: initialResult, visit });
+}
+
+export function childDiscoverer(element: ReactElement): ReactNode {
+ return element.props?.children;
+}
+
+export function routeElementDiscoverer(element: ReactElement): ReactNode {
+ if (element.props?.path && element.props?.element) {
+ return element.props?.element;
+ }
+ return undefined;
+}
diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx
index d69bb7e721..f477674f4d 100644
--- a/packages/core-api/src/plugin/Plugin.tsx
+++ b/packages/core-api/src/plugin/Plugin.tsx
@@ -14,10 +14,15 @@
* limitations under the License.
*/
-import { PluginConfig, PluginOutput, BackstagePlugin } from './types';
+import {
+ PluginConfig,
+ PluginOutput,
+ BackstagePlugin,
+ Extension,
+} from './types';
import { AnyApiFactory } from '../apis';
-export class PluginImpl {
+export class PluginImpl implements BackstagePlugin {
private storedOutput?: PluginOutput[];
constructor(private readonly config: PluginConfig) {}
@@ -65,6 +70,10 @@ export class PluginImpl {
return this.storedOutput;
}
+ provide(extension: Extension): T {
+ return extension.expose(this);
+ }
+
toString() {
return `plugin{${this.config.id}}`;
}
diff --git a/packages/core-api/src/plugin/collectors.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx
new file mode 100644
index 0000000000..f040cfd433
--- /dev/null
+++ b/packages/core-api/src/plugin/collectors.test.tsx
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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, { PropsWithChildren } from 'react';
+import { createRouteRef } from '../routing';
+import { createPlugin } from './Plugin';
+import {
+ createRoutableExtension,
+ createComponentExtension,
+} from '../extensions';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import {
+ traverseElementTree,
+ childDiscoverer,
+ routeElementDiscoverer,
+} from '../extensions/traversal';
+import { pluginCollector } from './collectors';
+
+const mockConfig = () => ({ path: '/foo', title: 'Foo' });
+const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}>;
+
+const pluginA = createPlugin({ id: 'my-plugin-a' });
+const pluginB = createPlugin({ id: 'my-plugin-b' });
+const pluginC = createPlugin({ id: 'my-plugin-c' });
+
+const ref1 = createRouteRef(mockConfig());
+const ref2 = createRouteRef(mockConfig());
+
+const Extension1 = pluginA.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
+);
+const Extension2 = pluginB.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref2 }),
+);
+const Extension3 = pluginA.provide(
+ createComponentExtension({ component: MockComponent }),
+);
+const Extension4 = pluginB.provide(
+ createComponentExtension({ component: MockComponent }),
+);
+const Extension5 = pluginC.provide(
+ createComponentExtension({ component: MockComponent }),
+);
+
+describe('collection', () => {
+ it('should collect the plugins', () => {
+ const root = (
+
+
+
+
+
+
+
+ {[
]}
+ Some text here shouldn't be a problem
+
+ {null}
+
+
+
+
+ {false}
+ {true}
+ {0}
+
+
+
+ } />
+
+
+
+ );
+
+ const { plugins } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ plugins: pluginCollector,
+ },
+ });
+
+ expect(plugins).toEqual(new Set([pluginA, pluginB, pluginC]));
+ });
+});
diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts
new file mode 100644
index 0000000000..a222746f9e
--- /dev/null
+++ b/packages/core-api/src/plugin/collectors.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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.
+ */
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { BackstagePlugin } from './types';
+import { getComponentData } from '../extensions';
+import { createCollector } from '../extensions/traversal';
+
+export const pluginCollector = createCollector(
+ new Set(),
+ (acc, node) => {
+ const plugin = getComponentData(node, 'core.plugin');
+ if (plugin) {
+ acc.add(plugin);
+ }
+ },
+);
diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts
index 855264c5f4..dd19948a0a 100644
--- a/packages/core-api/src/plugin/types.ts
+++ b/packages/core-api/src/plugin/types.ts
@@ -66,10 +66,15 @@ export type PluginOutput =
| RedirectRouteOutput
| FeatureFlagOutput;
+export type Extension = {
+ expose(plugin: BackstagePlugin): T;
+};
+
export type BackstagePlugin = {
getId(): string;
output(): PluginOutput[];
getApis(): Iterable;
+ provide(extension: Extension): T;
};
export type PluginConfig = {
diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx
new file mode 100644
index 0000000000..c2eefa7e82
--- /dev/null
+++ b/packages/core-api/src/routing/collectors.test.tsx
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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, { PropsWithChildren } from 'react';
+import { routeCollector, routeParentCollector } from './collectors';
+
+import {
+ traverseElementTree,
+ childDiscoverer,
+ routeElementDiscoverer,
+} from '../extensions/traversal';
+import { createRouteRef } from './RouteRef';
+import { createPlugin } from '../plugin';
+import { createRoutableExtension } from '../extensions';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+
+const mockConfig = () => ({ path: '/foo', title: 'Foo' });
+const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}>;
+
+const plugin = createPlugin({ id: 'my-plugin' });
+
+const ref1 = createRouteRef(mockConfig());
+const ref2 = createRouteRef(mockConfig());
+const ref3 = createRouteRef(mockConfig());
+const ref4 = createRouteRef(mockConfig());
+const ref5 = createRouteRef(mockConfig());
+
+const Extension1 = plugin.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
+);
+const Extension2 = plugin.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref2 }),
+);
+const Extension3 = plugin.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref3 }),
+);
+const Extension4 = plugin.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref4 }),
+);
+const Extension5 = plugin.provide(
+ createRoutableExtension({ component: MockComponent, mountPoint: ref5 }),
+);
+
+describe('discovery', () => {
+ it('should collect routes', () => {
+ const list = [
+ ,
+ ,
+
+
+
,
+ ];
+
+ const root = (
+
+
+
+
+
+
+
+ Some text here shouldn't be a problem
+
+ {null}
+
+
+
+
+ {false}
+ {list}
+ {true}
+ {0}
+
+
+
+ } />
+
+
+
+ );
+
+ const { routes, routeParents } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routes: routeCollector,
+ routeParents: routeParentCollector,
+ },
+ });
+ expect(routes).toEqual(
+ new Map([
+ [ref1, '/foo'],
+ [ref2, '/bar/:id'],
+ [ref3, '/baz'],
+ [ref4, '/divsoup'],
+ [ref5, '/blop'],
+ ]),
+ );
+
+ expect(routeParents).toEqual(
+ new Map([
+ [ref1, undefined],
+ [ref2, ref1],
+ [ref3, ref2],
+ [ref4, undefined],
+ [ref5, ref1],
+ ]),
+ );
+ });
+
+ it('should handle all react router Route patterns', () => {
+ const root = (
+
+
+
+
+
+
+
+ }
+ />
+ }>
+ } />
+
+
+
+
+ );
+
+ const { routes, routeParents } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routes: routeCollector,
+ routeParents: routeParentCollector,
+ },
+ });
+ expect(routes).toEqual(
+ new Map([
+ [ref1, '/foo'],
+ [ref2, '/bar/:id'],
+ [ref3, '/baz'],
+ [ref4, '/divsoup'],
+ [ref5, '/blop'],
+ ]),
+ );
+ expect(routeParents).toEqual(
+ new Map([
+ [ref1, undefined],
+ [ref2, ref1],
+ [ref3, undefined],
+ [ref4, ref3],
+ [ref5, ref3],
+ ]),
+ );
+ });
+
+ it('should not visit the same element twice', () => {
+ const element = ;
+
+ expect(() =>
+ traverseElementTree({
+ root: (
+
+ {element}
+ {element}
+
+ ),
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routes: routeCollector,
+ routeParents: routeParentCollector,
+ },
+ }),
+ ).toThrow(`Visited element Extension(MockComponent) twice`);
+ });
+});
diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx
new file mode 100644
index 0000000000..fab0cbe112
--- /dev/null
+++ b/packages/core-api/src/routing/collectors.tsx
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { isValidElement, ReactNode } from 'react';
+import { RouteRef } from '../routing/types';
+import { getComponentData } from '../extensions';
+import { createCollector } from '../extensions/traversal';
+
+export const routeCollector = createCollector(
+ new Map(),
+ (acc, node, parent) => {
+ if (parent.props.element === node) {
+ return;
+ }
+
+ const path: string | undefined = node.props?.path;
+ const element: ReactNode = node.props?.element;
+
+ const routeRef = getComponentData(node, 'core.mountPoint');
+ if (routeRef) {
+ if (!path) {
+ throw new Error('Mounted routable extension must have a path');
+ }
+ acc.set(routeRef, path);
+ } else if (isValidElement(element)) {
+ const elementRouteRef = getComponentData(
+ element,
+ 'core.mountPoint',
+ );
+ if (elementRouteRef) {
+ if (!path) {
+ throw new Error('Route element must have a path');
+ }
+ acc.set(elementRouteRef, path);
+ }
+ }
+ },
+);
+
+export const routeParentCollector = createCollector(
+ new Map(),
+ (acc, node, parent, parentRouteRef?: RouteRef) => {
+ if (parent.props.element === node) {
+ return parentRouteRef;
+ }
+
+ const element: ReactNode = node.props?.element;
+
+ let nextParent = parentRouteRef;
+
+ const routeRef = getComponentData(node, 'core.mountPoint');
+ if (routeRef) {
+ acc.set(routeRef, parentRouteRef);
+ nextParent = routeRef;
+ } else if (isValidElement(element)) {
+ const elementRouteRef = getComponentData(
+ element,
+ 'core.mountPoint',
+ );
+
+ if (elementRouteRef) {
+ acc.set(elementRouteRef, parentRouteRef);
+ nextParent = elementRouteRef;
+ }
+ }
+
+ return nextParent;
+ },
+);