frontend-app-api: split prepared app wiring helpers
Extract the prepared app tree and API factory lifecycle helpers so prepareSpecializedApp reads as orchestration, while keeping the finalization flow documented and behaviorally unchanged. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
ApiBlueprint,
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
FrontendFeature,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isInternalFrontendModule,
|
||||
toInternalFrontendModule,
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
import { ErrorCollector } from './createErrorCollector';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
import { type ExtensionPredicateContext } from './predicates';
|
||||
|
||||
export type ApiFactoryEntry = {
|
||||
node: AppNode;
|
||||
pluginId: string;
|
||||
factory: AnyApiFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers feature flag declarations on an already prepared API holder.
|
||||
*
|
||||
* This is primarily used when bootstrap reuses APIs from a provided session
|
||||
* state rather than building a fresh registry from bootstrap-visible factories.
|
||||
*/
|
||||
export function registerFeatureFlagDeclarationsInHolder(
|
||||
apis: ApiHolder,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
const featureFlagApi = apis.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
registerFeatureFlagDeclarations(featureFlagApi, features);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates the feature flags API factory so plugin and module declarations are
|
||||
* registered whenever that API is instantiated.
|
||||
*/
|
||||
export function wrapFeatureFlagApiFactory(
|
||||
factory: AnyApiFactory,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
if (factory.api.id !== featureFlagsApiRef.id) {
|
||||
return factory;
|
||||
}
|
||||
|
||||
return {
|
||||
...factory,
|
||||
factory(deps) {
|
||||
const featureFlagApi = factory.factory(
|
||||
deps,
|
||||
) as typeof featureFlagsApiRef.T;
|
||||
registerFeatureFlagDeclarations(featureFlagApi, features);
|
||||
return featureFlagApi;
|
||||
},
|
||||
} as AnyApiFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles deferred API factories into the finalized API registry.
|
||||
*
|
||||
* It preserves bootstrap-frozen APIs, allows safe deferred additions, and
|
||||
* reports cases where bootstrap-visible extensions relied on APIs that only
|
||||
* became available during finalization.
|
||||
*/
|
||||
export function syncFinalApiFactories(options: {
|
||||
deferredApiNodes: Iterable<AppNode>;
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
apiResolver: FrontendApiResolver;
|
||||
collector: ErrorCollector;
|
||||
features: FrontendFeature[];
|
||||
bootstrapApiFactoryEntries: ReadonlyMap<string, ApiFactoryEntry>;
|
||||
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
|
||||
predicateContext: ExtensionPredicateContext;
|
||||
}) {
|
||||
const finalApiEntries = collectApiFactoryEntries({
|
||||
apiNodes: options.deferredApiNodes,
|
||||
collector: options.collector,
|
||||
predicateContext: options.predicateContext,
|
||||
entries: new Map(options.bootstrapApiFactoryEntries),
|
||||
});
|
||||
// Only newly introduced or still-safe overrides are registered here. Any
|
||||
// bootstrap-materialized API remains frozen for the lifetime of the app.
|
||||
const changedEntries = Array.from(finalApiEntries.values()).filter(entry => {
|
||||
const bootstrapEntry = options.bootstrapApiFactoryEntries.get(
|
||||
entry.factory.api.id,
|
||||
);
|
||||
if (!bootstrapEntry) {
|
||||
return true;
|
||||
}
|
||||
if (bootstrapEntry.factory === entry.factory) {
|
||||
return false;
|
||||
}
|
||||
if (options.apiResolver.isMaterialized(entry.factory.api.id)) {
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
|
||||
message:
|
||||
`Extension '${entry.node.spec.id}' tried to override API ` +
|
||||
`'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` +
|
||||
'The bootstrap implementation was kept and the deferred override was ignored.',
|
||||
context: {
|
||||
node: entry.node,
|
||||
apiRefId: entry.factory.api.id,
|
||||
bootstrapNode: bootstrapEntry.node,
|
||||
pluginId: entry.pluginId,
|
||||
bootstrapPluginId: bootstrapEntry.pluginId,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const changedFactories = changedEntries.map(entry =>
|
||||
wrapFeatureFlagApiFactory(entry.factory, options.features),
|
||||
);
|
||||
options.appApiRegistry.setAll(changedFactories);
|
||||
options.apiResolver.invalidate(
|
||||
changedFactories.map(factory => factory.api.id),
|
||||
);
|
||||
for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) {
|
||||
if (
|
||||
options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) ||
|
||||
!finalApiEntries.has(bootstrapAccess.apiRefId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
|
||||
message:
|
||||
`Extension '${bootstrapAccess.node.spec.id}' tried to access API ` +
|
||||
`'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` +
|
||||
'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.',
|
||||
context: {
|
||||
node: bootstrapAccess.node,
|
||||
apiRefId: bootstrapAccess.apiRefId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_API_HOLDER: ApiHolder = {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
function registerFeatureFlagDeclarations(
|
||||
featureFlagApi: typeof featureFlagsApiRef.T,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
for (const feature of features) {
|
||||
if (OpaqueFrontendPlugin.isType(feature)) {
|
||||
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isInternalFrontendModule(feature)) {
|
||||
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.pluginId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates API extension subtrees in isolation and extracts the factories
|
||||
* they provide without mutating the live app tree.
|
||||
*
|
||||
* The collected entries are later used both for bootstrap registration and for
|
||||
* the finalization-time reconciliation of deferred API roots.
|
||||
*/
|
||||
export function collectApiFactoryEntries(options: {
|
||||
apiNodes: Iterable<AppNode>;
|
||||
collector: ErrorCollector;
|
||||
predicateContext?: ExtensionPredicateContext;
|
||||
entries?: Map<string, ApiFactoryEntry>;
|
||||
}): Map<string, ApiFactoryEntry> {
|
||||
const factoriesById = options.entries ?? new Map<string, ApiFactoryEntry>();
|
||||
for (const apiNode of options.apiNodes) {
|
||||
// API extensions are instantiated in isolation so we can inspect the
|
||||
// produced factories without mutating the live app tree.
|
||||
const detachedApiNode = instantiateAppNodeSubtree({
|
||||
rootNode: apiNode,
|
||||
apis: EMPTY_API_HOLDER,
|
||||
collector: options.collector,
|
||||
predicateContext: options.predicateContext,
|
||||
writeNodeInstances: false,
|
||||
reuseExistingInstances: false,
|
||||
});
|
||||
if (!detachedApiNode) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = detachedApiNode.instance?.getData(
|
||||
ApiBlueprint.dataRefs.factory,
|
||||
);
|
||||
if (apiFactory) {
|
||||
const apiRefId = apiFactory.api.id;
|
||||
const ownerId = getApiOwnerId(apiRefId);
|
||||
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
|
||||
const existingFactory = factoriesById.get(apiRefId);
|
||||
|
||||
// This allows modules to override factories provided by the plugin, but
|
||||
// it rejects API overrides from other plugins. In the event of a
|
||||
// conflict, the owning plugin is attempted to be inferred from the API
|
||||
// reference ID.
|
||||
if (existingFactory && existingFactory.pluginId !== pluginId) {
|
||||
const shouldReplace =
|
||||
ownerId === pluginId && existingFactory.pluginId !== ownerId;
|
||||
const acceptedPluginId = shouldReplace
|
||||
? pluginId
|
||||
: existingFactory.pluginId;
|
||||
const rejectedPluginId = shouldReplace
|
||||
? existingFactory.pluginId
|
||||
: pluginId;
|
||||
|
||||
options.collector.report({
|
||||
code: 'API_FACTORY_CONFLICT',
|
||||
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
apiRefId,
|
||||
pluginId: rejectedPluginId,
|
||||
existingPluginId: acceptedPluginId,
|
||||
},
|
||||
});
|
||||
if (shouldReplace) {
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
node: apiNode,
|
||||
factory: apiFactory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
node: apiNode,
|
||||
factory: apiFactory,
|
||||
});
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'API_EXTENSION_INVALID',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return factoriesById;
|
||||
}
|
||||
|
||||
// TODO(Rugvip): It would be good if this was more explicit, but I think that
|
||||
// might need to wait for some future update for API factories.
|
||||
function getApiOwnerId(apiRefId: string): string {
|
||||
const [prefix, ...rest] = apiRefId.split('.');
|
||||
if (!prefix) {
|
||||
return apiRefId;
|
||||
}
|
||||
if (prefix === 'core') {
|
||||
return 'app';
|
||||
}
|
||||
if (prefix === 'plugin' && rest[0]) {
|
||||
return rest[0];
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
AppNodeInstance,
|
||||
AppTree,
|
||||
coreExtensionData,
|
||||
ExtensionDataRef,
|
||||
ExtensionFactoryMiddleware,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { ErrorCollector } from './createErrorCollector';
|
||||
import {
|
||||
AppTreeApiProxy,
|
||||
instantiateAndInitializePhaseTree,
|
||||
RouteResolutionApiProxy,
|
||||
} from './phaseApis';
|
||||
|
||||
export type BootstrapClassification = {
|
||||
deferredApiRoots: Set<AppNode>;
|
||||
deferredElementRoots: Set<AppNode>;
|
||||
deferredRoots: Set<AppNode>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Instantiates the bootstrap-visible portion of the app tree and returns the
|
||||
* element that should be rendered while the prepared app is still incomplete.
|
||||
*
|
||||
* The bootstrap tree deliberately stops at the session boundary so sign-in and
|
||||
* other deferred content can be handled separately during finalization.
|
||||
*/
|
||||
export function createBootstrapApp(options: {
|
||||
tree: AppTree;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
routeResolutionApi: RouteResolutionApiProxy;
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
disableSignIn?: boolean;
|
||||
skipBootstrapChild?(ctx: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
child: AppNode;
|
||||
}): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
hasSignInPage(node?: AppNode): boolean;
|
||||
}): {
|
||||
bootstrapApp: { element: JSX.Element; tree: AppTree };
|
||||
requiresSignIn: boolean;
|
||||
} {
|
||||
const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get(
|
||||
'signInPage',
|
||||
)?.[0];
|
||||
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.routeResolutionApi,
|
||||
appTreeApi: options.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
stopAtAttachment: ({ node, input }) =>
|
||||
isSessionBoundaryAttachment(node, input),
|
||||
skipChild: options.skipBootstrapChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected bootstrap tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrapApp: {
|
||||
element,
|
||||
tree: options.tree,
|
||||
},
|
||||
requiresSignIn:
|
||||
!options.disableSignIn && options.hasSignInPage(signInPageNode),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the app tree into bootstrap-visible and deferred regions.
|
||||
*
|
||||
* Predicate-gated roots are deferred to finalization, while any predicate that
|
||||
* still leaks into the bootstrap-visible region is reported and ignored.
|
||||
*/
|
||||
export function classifyBootstrapTree(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): BootstrapClassification {
|
||||
const apiNodes = options.tree.root.edges.attachments.get('apis') ?? [];
|
||||
const deferredApiRoots = new Set(
|
||||
apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)),
|
||||
);
|
||||
const appRootElementNodes =
|
||||
getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? [];
|
||||
const deferredElementRoots = new Set(
|
||||
appRootElementNodes.filter(elementNode =>
|
||||
subtreeContainsPredicate(elementNode),
|
||||
),
|
||||
);
|
||||
const deferredRoots = new Set<AppNode>([
|
||||
...deferredApiRoots,
|
||||
...deferredElementRoots,
|
||||
]);
|
||||
const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, {
|
||||
deferredRoots,
|
||||
});
|
||||
|
||||
for (const node of bootstrapNodes) {
|
||||
if (node.spec.if === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
|
||||
message:
|
||||
`Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` +
|
||||
"Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.",
|
||||
context: {
|
||||
node,
|
||||
},
|
||||
});
|
||||
(node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
deferredApiRoots,
|
||||
deferredElementRoots,
|
||||
deferredRoots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the app tree for finalization by removing the bootstrap-only
|
||||
* sign-in attachment from the app root boundary.
|
||||
*/
|
||||
export function prepareFinalizedTree(options: { tree: AppTree }) {
|
||||
for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) {
|
||||
const attachments = appRootNode.edges.attachments as Map<string, AppNode[]>;
|
||||
attachments.delete('signInPage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears instances inside the finalization boundary so those nodes can be
|
||||
* re-instantiated with finalized predicate context and API availability.
|
||||
*/
|
||||
export function clearFinalizationBoundaryInstances(tree: AppTree) {
|
||||
clearNodeInstance(tree.root);
|
||||
|
||||
const visited = new Set<AppNode>();
|
||||
function visit(node: AppNode) {
|
||||
if (visited.has(node)) {
|
||||
return;
|
||||
}
|
||||
visited.add(node);
|
||||
clearNodeInstance(node);
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
// app/root.elements is allowed to keep its bootstrap instances so we only
|
||||
// re-run the parts of the boundary that actually change at finalization.
|
||||
if (node.spec.id === 'app/root' && input === 'elements') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const appRootNode of getFinalizationBoundaryNodes(tree)) {
|
||||
visit(appRootNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the attachment that separates bootstrap rendering from the
|
||||
* children that are deferred until finalization.
|
||||
*/
|
||||
export function isSessionBoundaryAttachment(node: AppNode, input: string) {
|
||||
return node.spec.id === 'app/root' && input === 'children';
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a synthetic finalization child that throws the captured bootstrap
|
||||
* error when rendered.
|
||||
*
|
||||
* This lets the finalized tree reuse the normal app root error boundary rather
|
||||
* than introducing a separate error rendering path.
|
||||
*/
|
||||
export function attachThrowingFinalizationChild(tree: AppTree, error: Error) {
|
||||
const bootstrapChildNode =
|
||||
getAppRootNode(tree)?.edges.attachments.get('children')?.[0];
|
||||
if (!bootstrapChildNode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function ThrowBootstrapError(): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This synthetic child gives the finalized tree a stable place to rethrow
|
||||
// bootstrap failures through the normal extension boundary stack.
|
||||
(bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = {
|
||||
getDataRefs() {
|
||||
return [coreExtensionData.reactElement].values();
|
||||
},
|
||||
getData<TValue>(dataRef: ExtensionDataRef<TValue>) {
|
||||
if (dataRef.id === coreExtensionData.reactElement.id) {
|
||||
return (<ThrowBootstrapError />) as TValue;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAppRootNode(tree: AppTree) {
|
||||
return tree.nodes.get('app/root');
|
||||
}
|
||||
|
||||
function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] {
|
||||
const nodes = new Set<AppNode>();
|
||||
const appRootNode = getAppRootNode(tree);
|
||||
if (appRootNode) {
|
||||
nodes.add(appRootNode);
|
||||
}
|
||||
const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0];
|
||||
if (attachedAppRootNode) {
|
||||
nodes.add(attachedAppRootNode);
|
||||
}
|
||||
return Array.from(nodes);
|
||||
}
|
||||
|
||||
function clearNodeInstance(node: AppNode) {
|
||||
(node as AppNode & { instance?: AppNodeInstance }).instance = undefined;
|
||||
}
|
||||
|
||||
function collectBootstrapVisibleNodes(
|
||||
tree: AppTree,
|
||||
options?: { deferredRoots?: Set<AppNode> },
|
||||
) {
|
||||
const visibleNodes = new Set<AppNode>();
|
||||
|
||||
function visit(node: AppNode) {
|
||||
if (visibleNodes.has(node)) {
|
||||
return;
|
||||
}
|
||||
visibleNodes.add(node);
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
if (isSessionBoundaryAttachment(node, input)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (options?.deferredRoots?.has(child)) {
|
||||
continue;
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(tree.root);
|
||||
|
||||
return visibleNodes;
|
||||
}
|
||||
|
||||
function subtreeContainsPredicate(root: AppNode) {
|
||||
const visited = new Set<AppNode>();
|
||||
|
||||
function visit(node: AppNode): boolean {
|
||||
if (visited.has(node)) {
|
||||
return false;
|
||||
}
|
||||
visited.add(node);
|
||||
|
||||
if (node.spec.if !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const children of node.edges.attachments.values()) {
|
||||
for (const child of children) {
|
||||
if (visit(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return visit(root);
|
||||
}
|
||||
Reference in New Issue
Block a user