diff --git a/packages/core-api/src/routing/discovery.test.tsx b/packages/core-api/src/routing/discovery.test.tsx
index be553d9ce5..c702811529 100644
--- a/packages/core-api/src/routing/discovery.test.tsx
+++ b/packages/core-api/src/routing/discovery.test.tsx
@@ -15,7 +15,13 @@
*/
import React, { PropsWithChildren } from 'react';
-import { collectRoutes, collectRouteParents } from './discovery';
+import {
+ traverseElementTree,
+ childDiscoverer,
+ routeElementDiscoverer,
+ routeCollector,
+ routeParentCollector,
+} from './discovery';
import { createRouteRef } from './RouteRef';
import { createPlugin } from '../plugin';
import { createRoutableExtension } from '../extensions';
@@ -86,7 +92,15 @@ describe('discovery', () => {
);
- expect(collectRoutes(root)).toEqual(
+ const { routes, routeParents } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routes: routeCollector,
+ routeParents: routeParentCollector,
+ },
+ });
+ expect(routes).toEqual(
new Map([
[ref1, '/foo'],
[ref2, '/bar/:id'],
@@ -96,7 +110,7 @@ describe('discovery', () => {
]),
);
- expect(collectRouteParents(root)).toEqual(
+ expect(routeParents).toEqual(
new Map([
[ref1, undefined],
[ref2, ref1],
@@ -129,7 +143,15 @@ describe('discovery', () => {
);
- expect(collectRoutes(root)).toEqual(
+ const { routes, routeParents } = traverseElementTree({
+ root,
+ discoverers: [childDiscoverer, routeElementDiscoverer],
+ collectors: {
+ routes: routeCollector,
+ routeParents: routeParentCollector,
+ },
+ });
+ expect(routes).toEqual(
new Map([
[ref1, '/foo'],
[ref2, '/bar/:id'],
@@ -138,7 +160,7 @@ describe('discovery', () => {
[ref5, '/blop'],
]),
);
- expect(collectRouteParents(root)).toEqual(
+ expect(routeParents).toEqual(
new Map([
[ref1, undefined],
[ref2, ref1],
@@ -153,12 +175,19 @@ describe('discovery', () => {
const element = ;
expect(() =>
- collectRoutes(
-
- {element}
- {element}
- ,
- ),
+ 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/discovery.tsx b/packages/core-api/src/routing/discovery.tsx
index b925c8349d..0189ae3a4e 100644
--- a/packages/core-api/src/routing/discovery.tsx
+++ b/packages/core-api/src/routing/discovery.tsx
@@ -18,81 +18,143 @@ import { isValidElement, ReactNode, ReactElement, Children } from 'react';
import { RouteRef } from './types';
import { getComponentData } from '../extensions';
-type TraverseFunc = (
- node: ReactElement,
- parent: ReactElement,
- context: Context,
-) => Generator<
- Context extends undefined
- ? { children: ReactNode; context?: Context }
- : { children: ReactNode; context: Context },
- void,
- void
->;
+type Discoverer = (element: ReactElement) => ReactNode;
-function traverse(
- options: { root: ReactElement; initialContext?: Context },
- traverseFunc: TraverseFunc,
-): void {
+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 nodes: Array<{
- children: ReactNode;
+ 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;
- context: Context;
- }> = [
+ contexts: { [name in string]: unknown };
+ };
+
+ const queue = [
{
- children: Children.toArray(options.root),
+ node: Children.toArray(options.root),
parent: options.root,
- context: options.initialContext!,
- },
+ contexts: {},
+ } as QueueItem,
];
- while (nodes.length !== 0) {
- const { children, parent, context } = nodes.shift()!;
+ while (queue.length !== 0) {
+ const { node, parent, contexts } = queue.shift()!;
- Children.forEach(children, child => {
- if (!isValidElement(child)) {
+ // 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(child)) {
- const anyType = child?.type as
+ 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(child);
+ visited.add(element);
- for (const next of traverseFunc(child, parent, context)) {
- nodes.push({
- children: next.children,
- context: next.context!,
- parent: child,
- });
+ 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 const collectRoutes = (root: ReactElement) => {
- const treeMap = new Map();
+export function childDiscoverer(element: ReactElement): ReactNode {
+ return element.props?.children;
+}
- traverse({ root }, function* traverseFunc(node, parent: ReactElement) {
- const { path, element, children } = node.props as {
- path?: string;
- element?: ReactNode;
- children?: ReactNode;
- };
+export function routeElementDiscoverer(element: ReactElement): ReactNode {
+ if (element.props?.path && element.props?.element) {
+ return element.props?.element;
+ }
+ return undefined;
+}
+
+function createCollector(
+ initialResult: Result,
+ visit: ReturnType>['visit'],
+): Collector {
+ return () => ({ accumulator: initialResult, visit });
+}
+
+export const routeCollector = createCollector(
+ new Map(),
+ (acc, node, parent) => {
if (parent.props.element === node) {
- yield { children };
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');
}
- treeMap.set(routeRef, path);
+ acc.set(routeRef, path);
} else if (isValidElement(element)) {
const elementRouteRef = getComponentData(
element,
@@ -102,55 +164,39 @@ export const collectRoutes = (root: ReactElement) => {
if (!path) {
throw new Error('Route element must have a path');
}
- treeMap.set(elementRouteRef, path);
+ acc.set(elementRouteRef, path);
}
- yield { children: element };
+ }
+ },
+);
+
+export const routeParentCollector = createCollector(
+ new Map(),
+ (acc, node, parent, parentRouteRef?: RouteRef) => {
+ if (parent.props.element === node) {
+ return parentRouteRef;
}
- yield { children };
- });
+ const element: ReactNode = node.props?.element;
- return treeMap;
-};
+ let nextParent = parentRouteRef;
-export const collectRouteParents = (root: ReactElement) => {
- const treeMap = new Map();
+ const routeRef = getComponentData(node, 'core.mountPoint');
+ if (routeRef) {
+ acc.set(routeRef, parentRouteRef);
+ nextParent = routeRef;
+ } else if (isValidElement(element)) {
+ const elementRouteRef = getComponentData(
+ element,
+ 'core.mountPoint',
+ );
- traverse(
- { root, initialContext: undefined },
- function* traverseFunc(node, parent, parentRouteRef) {
- const { element, children } = node.props as {
- element?: ReactNode;
- children?: ReactNode;
- };
- if (parent.props.element === node) {
- yield { children };
- return;
+ if (elementRouteRef) {
+ acc.set(elementRouteRef, parentRouteRef);
+ nextParent = elementRouteRef;
}
+ }
- let nextParent = parentRouteRef;
-
- const routeRef = getComponentData(node, 'core.mountPoint');
- if (routeRef) {
- treeMap.set(routeRef, parentRouteRef);
- nextParent = routeRef;
- } else if (isValidElement(element)) {
- const elementRouteRef = getComponentData(
- element,
- 'core.mountPoint',
- );
-
- if (elementRouteRef) {
- treeMap.set(elementRouteRef, parentRouteRef);
- nextParent = elementRouteRef;
- yield { children: element, context: elementRouteRef };
- } else {
- yield { children: element, context: parentRouteRef };
- }
- }
- yield { children, context: nextParent };
- },
- );
-
- return treeMap;
-};
+ return nextParent;
+ },
+);