From 706868141eafb9d5e5626ad29595b6d5e5ba979e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 26 Nov 2020 19:22:59 +0100 Subject: [PATCH] core-api: refactor route discovery to do a single pluggable sweep --- .../core-api/src/routing/discovery.test.tsx | 51 +++- packages/core-api/src/routing/discovery.tsx | 224 +++++++++++------- 2 files changed, 175 insertions(+), 100 deletions(-) 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; + }, +);