chrome-devtools-frontend
Version:
Chrome DevTools UI
694 lines (599 loc) • 26.4 kB
text/typescript
// Copyright 2025 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../../core/common/common.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as Platform from '../../../core/platform/platform.js';
import * as Protocol from '../../../generated/protocol.js';
import type * as Handlers from '../handlers/handlers.js';
import * as Helpers from '../helpers/helpers.js';
import type * as Lantern from '../lantern/lantern.js';
import * as Types from '../types/types.js';
import {
InsightCategory,
InsightKeys,
type InsightModel,
type InsightSetContext,
type InsightSetContextWithNavigation,
type PartialInsightModel,
type RelatedEventsMap,
} from './types.js';
export const UIStrings = {
/**
* @description Title of an insight that recommends avoiding chaining critical requests.
*/
title: 'Network dependency tree',
/**
* @description Description of an insight that recommends avoiding chaining critical requests.
*/
description:
'[Avoid chaining critical requests](https://developer.chrome.com/docs/lighthouse/performance/critical-request-chains) by reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load.',
/**
* @description Description of the warning that recommends avoiding chaining critical requests.
*/
warningDescription:
'Avoid chaining critical requests by reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load.',
/**
* @description Text status indicating that there isn't long chaining critical network requests.
*/
noNetworkDependencyTree: 'No rendering tasks impacted by network dependencies',
/**
* @description Text for the maximum critical path latency. This refers to the longest chain of network requests that
* the browser must download before it can render the page.
*/
maxCriticalPathLatency: 'Max critical path latency:',
/** Label for a column in a data table; entries will be the network request */
columnRequest: 'Request',
/** Label for a column in a data table; entries will be the time from main document till current network request. */
columnTime: 'Time',
/**
* @description Title of the table of the detected preconnect origins.
*/
preconnectOriginsTableTitle: 'Preconnected origins',
/**
* @description Description of the table of the detected preconnect origins.
*/
preconnectOriginsTableDescription:
'[preconnect](https://developer.chrome.com/docs/lighthouse/performance/uses-rel-preconnect/) hints help the browser establish a connection earlier in the page load, saving time when the first request for that origin is made. The following are the origins that the page preconnected to.',
/**
* @description Text status indicating that there isn't any preconnected origins.
*/
noPreconnectOrigins: 'no origins were preconnected',
/**
* @description A warning message that is shown when found more than 4 preconnected links
*/
tooManyPreconnectLinksWarning:
'More than 4 `preconnect` connections were found. These should be used sparingly and only to the most important origins.',
/**
* @description A warning message that is shown when the user added preconnect for some unnecessary origins.
*/
unusedWarning: 'Unused preconnect. Only use `preconnect` for origins that the page is likely to request.',
/**
* @description A warning message that is shown when the user forget to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preconnect links.
* */
crossoriginWarning: 'Unused preconnect. Check that the `crossorigin` attribute is used properly.',
/**
* @description Label for a column in a data table; entries will be the source of the origin.
*/
columnSource: 'Source',
/**
* @description Text status indicating that there isn't preconnect candidates.
*/
noPreconnectCandidates: 'No additional origins are good candidates for preconnecting',
/**
* @description Title of the table that shows the origins that the page should have preconnected to.
*/
estSavingTableTitle: 'Preconnect candidates',
/**
* @description Description of the table that recommends preconnecting to the origins to save time.
*/
estSavingTableDescription:
'Add [preconnect](https://developer.chrome.com/docs/lighthouse/performance/uses-rel-preconnect/) hints to your most important origins, but try to use no more than 4.',
/**
* @description Label for a column in a data table; entries will be the origin of a web resource
*/
columnOrigin: 'Origin',
/**
* @description Label for a column in a data table; entries will be the number of milliseconds the user could reduce page load by if they implemented the suggestions.
*/
columnWastedMs: 'Est LCP savings',
} as const;
const str_ = i18n.i18n.registerUIStrings('models/trace/insights/NetworkDependencyTree.ts', UIStrings);
export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
// XHRs are fetched at High priority, but we exclude them, as they are unlikely to be critical
// Images are also non-critical.
const nonCriticalResourceTypes = new Set<Protocol.Network.ResourceType>([
Protocol.Network.ResourceType.Image,
Protocol.Network.ResourceType.XHR,
Protocol.Network.ResourceType.Fetch,
Protocol.Network.ResourceType.EventSource,
]);
// Preconnect establishes a "clean" socket. Chrome's socket manager will keep an unused socket
// around for 10s. Meaning, the time delta between processing preconnect a request should be <10s,
// otherwise it's wasted. We add a 5s margin so we are sure to capture all key requests.
// @see https://github.com/GoogleChrome/lighthouse/issues/3106#issuecomment-333653747
const PRECONNECT_SOCKET_MAX_IDLE_IN_MS = Types.Timing.Milli(15_000);
const IGNORE_THRESHOLD_IN_MILLISECONDS = Types.Timing.Milli(50);
export const TOO_MANY_PRECONNECTS_THRESHOLD = 4;
export interface CriticalRequestNode {
request: Types.Events.SyntheticNetworkRequest;
timeFromInitialRequest: Types.Timing.Micro;
children: CriticalRequestNode[];
isLongest?: boolean;
// Store all the requests that appear in any chains this request appears in.
// Use set to avoid duplication.
relatedRequests: Set<Types.Events.SyntheticNetworkRequest>;
}
export type PreconnectedOrigin = PreconnectedOriginFromDom|PreconnectedOriginFromResponseHeader;
export interface PreconnectedOriginFromDom {
// eslint-disable-next-line @typescript-eslint/naming-convention
node_id: Protocol.DOM.BackendNodeId;
frame?: string;
url: string;
unused: boolean;
crossorigin: boolean;
source: 'DOM';
}
export interface PreconnectedOriginFromResponseHeader {
url: string;
headerText: string;
request: Types.Events.SyntheticNetworkRequest;
unused: boolean;
crossorigin: boolean;
source: 'ResponseHeader';
}
export interface PreconnectCandidate {
origin: Platform.DevToolsPath.UrlString;
wastedMs: Types.Timing.Milli;
}
export type NetworkDependencyTreeInsightModel = InsightModel<typeof UIStrings, {
rootNodes: CriticalRequestNode[],
maxTime: Types.Timing.Micro,
fail: boolean,
preconnectedOrigins: PreconnectedOrigin[],
preconnectCandidates: PreconnectCandidate[],
}>;
function finalize(partialModel: PartialInsightModel<NetworkDependencyTreeInsightModel>):
NetworkDependencyTreeInsightModel {
return {
insightKey: InsightKeys.NETWORK_DEPENDENCY_TREE,
strings: UIStrings,
title: i18nString(UIStrings.title),
description: i18nString(UIStrings.description),
category: InsightCategory.LCP,
state: partialModel.fail ? 'fail' : 'pass',
...partialModel,
};
}
function isCritical(request: Types.Events.SyntheticNetworkRequest, context: InsightSetContextWithNavigation): boolean {
// The main resource is always critical.
if (request.args.data.requestId === context.navigationId) {
return true;
}
// Treat any preloaded resource as non-critical
if (request.args.data.isLinkPreload) {
return false;
}
// Iframes are considered High Priority but they are not render blocking
const isIframe = request.args.data.resourceType === Protocol.Network.ResourceType.Document &&
request.args.data.frame !== context.frameId;
if (nonCriticalResourceTypes.has(request.args.data.resourceType) || isIframe ||
// Treat any missed images, primarily favicons, as non-critical resources
request.args.data.mimeType.startsWith('image/')) {
return false;
}
// Requests that have no initiatorRequest are typically ambiguous late-load assets.
// Even on the off chance they were important, we don't have any parent to display for them.
const initiatorUrl =
request.args.data.initiator?.url || Helpers.Trace.getZeroIndexedStackTraceInEventPayload(request)?.at(0)?.url;
if (!initiatorUrl) {
return false;
}
const isBlocking = Helpers.Network.isSyntheticNetworkRequestEventRenderBlocking(request);
const isHighPriority = Helpers.Network.isSyntheticNetworkRequestHighPriority(request);
return isHighPriority || isBlocking;
}
function findMaxLeafNode(node: CriticalRequestNode): CriticalRequestNode {
if (node.children.length === 0) {
return node;
}
let maxLeaf = node.children[0];
for (const child of node.children) {
const leaf = findMaxLeafNode(child);
if (leaf.timeFromInitialRequest > maxLeaf.timeFromInitialRequest) {
maxLeaf = leaf;
}
}
return maxLeaf;
}
function sortRecursively(nodes: CriticalRequestNode[]): void {
for (const node of nodes) {
if (node.children.length > 0) {
node.children.sort((nodeA, nodeB) => {
const leafA = findMaxLeafNode(nodeA);
const leafB = findMaxLeafNode(nodeB);
return leafB.timeFromInitialRequest - leafA.timeFromInitialRequest;
});
sortRecursively(node.children);
}
}
}
function generateNetworkDependencyTree(context: InsightSetContextWithNavigation): {
rootNodes: CriticalRequestNode[],
maxTime: Types.Timing.Micro,
fail: boolean,
relatedEvents?: RelatedEventsMap,
} {
const rootNodes: CriticalRequestNode[] = [];
const relatedEvents: RelatedEventsMap = new Map();
let maxTime = Types.Timing.Micro(0);
let fail = false;
let longestChain: Types.Events.SyntheticNetworkRequest[] = [];
function addChain(path: Types.Events.SyntheticNetworkRequest[]): void {
if (path.length === 0) {
return;
}
if (path.length >= 2) {
fail = true;
}
const initialRequest = path[0];
const lastRequest = path[path.length - 1];
const totalChainTime = Types.Timing.Micro(lastRequest.ts + lastRequest.dur - initialRequest.ts);
if (totalChainTime > maxTime) {
maxTime = totalChainTime;
longestChain = path;
}
let currentNodes = rootNodes;
for (let depth = 0; depth < path.length; ++depth) {
const request = path[depth];
// find the request
let found = currentNodes.find(node => node.request === request);
if (!found) {
const timeFromInitialRequest = Types.Timing.Micro(request.ts + request.dur - initialRequest.ts);
found = {
request,
timeFromInitialRequest,
children: [],
relatedRequests: new Set(),
};
currentNodes.push(found);
}
path.forEach(request => found?.relatedRequests.add(request));
// TODO(b/372897712): When RelatedInsight supports markdown, remove
// UIStrings.warningDescription and use UIStrings.description.
relatedEvents.set(request, depth < 2 ? [] : [i18nString(UIStrings.warningDescription)]);
currentNodes = found.children;
}
}
// By default `traverse` will discover nodes in BFS-order regardless of dependencies, but
// here we need traversal in a topological sort order. We'll visit a node only when its
// dependencies have been met.
const seenNodes = new Set<Lantern.Graph.Node<Types.Events.SyntheticNetworkRequest>>();
function getNextNodes(node: Lantern.Graph.Node<Types.Events.SyntheticNetworkRequest>):
Array<Lantern.Graph.Node<Types.Events.SyntheticNetworkRequest>> {
return node.getDependents().filter(n => n.getDependencies().every(d => seenNodes.has(d)));
}
context.lantern?.graph.traverse((node, traversalPath) => {
seenNodes.add(node);
if (node.type !== 'network') {
return;
}
const networkNode = node;
if (!isCritical(networkNode.rawRequest, context)) {
return;
}
const networkPath = traversalPath.filter(node => node.type === 'network').reverse().map(node => node.rawRequest);
// Ignore if some ancestor is not a critical request.
if (networkPath.some(request => (!isCritical(request, context)))) {
return;
}
// Ignore non-network things (like data urls).
if (node.isNonNetworkProtocol) {
return;
}
addChain(networkPath);
}, getNextNodes);
// Mark the longest chain
if (longestChain.length > 0) {
let currentNodes = rootNodes;
for (const request of longestChain) {
const found = currentNodes.find(node => node.request === request);
if (found) {
found.isLongest = true;
currentNodes = found.children;
} else {
console.error('Some request in the longest chain is not found');
}
}
}
sortRecursively(rootNodes);
return {
rootNodes,
maxTime,
fail,
relatedEvents,
};
}
function getSecurityOrigin(url: string): Platform.DevToolsPath.UrlString {
const parsedURL = new Common.ParsedURL.ParsedURL(url);
return parsedURL.securityOrigin();
}
function handleLinkResponseHeaderPart(trimmedPart: string): {url: string, headerText: string}|null {
if (!trimmedPart) {
// Skip empty string
return null;
}
// Extract URL
const urlStart = trimmedPart.indexOf('<');
const urlEnd = trimmedPart.indexOf('>');
if (urlStart !== 0 || urlEnd === -1 || urlEnd <= urlStart) {
// Skip parts without a valid URI (must start with '<' and have a closing '>')
return null;
}
const url = trimmedPart.substring(urlStart + 1, urlEnd).trim();
if (!url) {
// Skip empty url
return null;
}
// Extract parameters string (everything after '>')
const paramsString = trimmedPart.substring(urlEnd + 1).trim();
if (paramsString) {
const params = paramsString.split(';');
for (const param of params) {
const trimmedParam = param.trim();
if (!trimmedParam) {
continue;
}
const eqIndex = trimmedParam.indexOf('=');
if (eqIndex === -1) {
// Skip malformed parameters without an '='
continue;
}
const paramName = trimmedParam.substring(0, eqIndex).trim().toLowerCase();
let paramValue = trimmedParam.substring(eqIndex + 1).trim();
// Remove quotes from value if present
if (paramValue.startsWith('"') && paramValue.endsWith('"')) {
paramValue = paramValue.substring(1, paramValue.length - 1);
}
if (paramName === 'rel' && paramValue === 'preconnect') {
// Found 'rel=preconnect', no need to process other parameters for this link
return {url, headerText: trimmedPart};
}
}
}
return null;
}
/**
* Parses an HTTP Link header string into an array of url and related header text.
*
* Export the function for test purpose.
* @param linkHeaderValue The value of the HTTP Link header (e.g., '</style.css>; rel=preload; as=style, <https://example.com>; rel="preconnect"').
* @returns An array of url and header text objects if it contains `rel=preconnect`.
*/
export function handleLinkResponseHeader(linkHeaderValue: string): Array<{url: string, headerText: string}> {
if (!linkHeaderValue) {
return [];
}
const preconnectedOrigins: Array<{url: string, headerText: string}> = [];
// const headerTextParts = linkHeaderValue.split(',');
for (let i = 0; i < linkHeaderValue.length;) {
const firstUrlEnd = linkHeaderValue.indexOf('>', i);
const commaIndex = linkHeaderValue.indexOf(',', firstUrlEnd);
const partEnd = commaIndex !== -1 ? commaIndex : linkHeaderValue.length;
const part = linkHeaderValue.substring(i, partEnd);
i = partEnd + 1;
const preconnectedOrigin = handleLinkResponseHeaderPart(part.trim());
if (preconnectedOrigin) {
preconnectedOrigins.push(preconnectedOrigin);
}
}
return preconnectedOrigins;
}
// Export the function for test purpose.
export function generatePreconnectedOrigins(
parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContextWithNavigation,
contextRequests: Types.Events.SyntheticNetworkRequest[],
preconnectCandidates: PreconnectCandidate[]): PreconnectedOrigin[] {
const preconnectedOrigins: PreconnectedOrigin[] = [];
for (const event of parsedTrace.NetworkRequests.linkPreconnectEvents) {
preconnectedOrigins.push({
node_id: event.args.data.node_id,
frame: event.args.data.frame,
url: event.args.data.url,
// For each origin the page wanted to preconnect to:
// - if we found no network requests to that origin at all then we issue a unused warning
unused: !contextRequests.some(
request => getSecurityOrigin(event.args.data.url) === getSecurityOrigin(request.args.data.url)),
// - else (we found network requests to the same origin) and if some of those network requests is too slow (if
// they are preconnect candidates), then we issue a unused warning with crossorigin hint
crossorigin: preconnectCandidates.some(candidate => candidate.origin === getSecurityOrigin(event.args.data.url)),
source: 'DOM',
});
}
const documentRequest =
parsedTrace.NetworkRequests.byTime.find(req => req.args.data.requestId === context.navigationId);
documentRequest?.args.data.responseHeaders?.forEach(header => {
if (header.name.toLowerCase() === 'link') {
const preconnectedOriginsFromResponseHeader = handleLinkResponseHeader(header.value); // , documentRequest);
preconnectedOriginsFromResponseHeader?.forEach(origin => preconnectedOrigins.push({
url: origin.url,
headerText: origin.headerText,
request: documentRequest,
// For each origin the page wanted to preconnect to:
// - if we found no network requests to that origin at all then we issue a unused warning
unused: !contextRequests.some(
request => getSecurityOrigin(origin.url) === getSecurityOrigin(request.args.data.url)),
// - else (we found network requests to the same origin) and if some of those network requests is too slow (if
// they are preconnect candidates), then we issue a unused warning with crossorigin hint
crossorigin: preconnectCandidates.some(candidate => candidate.origin === getSecurityOrigin(origin.url)),
source: 'ResponseHeader',
}));
}
});
return preconnectedOrigins;
}
function hasValidTiming(request: Types.Events.SyntheticNetworkRequest): boolean {
return !!request.args.data.timing && request.args.data.timing.connectEnd >= 0 &&
request.args.data.timing.connectStart >= 0;
}
function hasAlreadyConnectedToOrigin(request: Types.Events.SyntheticNetworkRequest): boolean {
const {timing} = request.args.data;
if (!timing) {
return false;
}
// When these values are given as -1, that means the page has
// a connection for this origin and paid these costs already.
if (timing.dnsStart === -1 && timing.dnsEnd === -1 && timing.connectStart === -1 && timing.connectEnd === -1) {
return true;
}
// Less understood: if the connection setup took no time at all, consider
// it the same as the above. It is unclear if this is correct, or is even possible.
if (timing.dnsEnd - timing.dnsStart === 0 && timing.connectEnd - timing.connectStart === 0) {
return true;
}
return false;
}
function socketStartTimeIsBelowThreshold(
request: Types.Events.SyntheticNetworkRequest, mainResource: Types.Events.SyntheticNetworkRequest): boolean {
const timeSinceMainEnd =
Math.max(0, request.args.data.syntheticData.sendStartTime - mainResource.args.data.syntheticData.finishTime) as
Types.Timing.Micro;
return Helpers.Timing.microToMilli(timeSinceMainEnd) < PRECONNECT_SOCKET_MAX_IDLE_IN_MS;
}
function candidateRequestsByOrigin(
parsedTrace: Handlers.Types.ParsedTrace, mainResource: Types.Events.SyntheticNetworkRequest,
contextRequests: Types.Events.SyntheticNetworkRequest[],
lcpGraphURLs: Set<string>): Map<string, Types.Events.SyntheticNetworkRequest[]> {
const origins = new Map<string, Types.Events.SyntheticNetworkRequest[]>();
contextRequests.forEach(request => {
if (!hasValidTiming(request)) {
return;
}
// Filter out all resources that are loaded by the document. Connections are already early.
if (parsedTrace.NetworkRequests.eventToInitiator.get(request) === mainResource) {
return;
}
const url = new URL(request.args.data.url);
// Filter out urls that do not have an origin (data, file, etc).
if (url.origin === 'null') {
return;
}
const mainOrigin = new URL(mainResource.args.data.url).origin;
// Filter out all resources that have the same origin. We're already connected.
if (url.origin === mainOrigin) {
return;
}
// Filter out anything that wasn't part of LCP. Only recommend important origins.
if (!lcpGraphURLs.has(request.args.data.url)) {
return;
}
// Filter out all resources where origins are already resolved.
if (hasAlreadyConnectedToOrigin(request)) {
return;
}
// Make sure the requests are below the PRECONNECT_SOCKET_MAX_IDLE_IN_MS (15s) mark.
if (!socketStartTimeIsBelowThreshold(request, mainResource)) {
return;
}
const originRequests = Platform.MapUtilities.getWithDefault(origins, url.origin, () => []);
originRequests.push(request);
});
return origins;
}
// Export the function for test purpose.
export function generatePreconnectCandidates(
parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContextWithNavigation,
contextRequests: Types.Events.SyntheticNetworkRequest[]): PreconnectCandidate[] {
if (!context.lantern) {
return [];
}
const mainResource = contextRequests.find(request => request.args.data.requestId === context.navigationId);
if (!mainResource) {
return [];
}
const {rtt, additionalRttByOrigin} = context.lantern.simulator.getOptions();
const lcpGraph = context.lantern.metrics.largestContentfulPaint.pessimisticGraph;
const fcpGraph = context.lantern.metrics.firstContentfulPaint.pessimisticGraph;
const lcpGraphURLs = new Set<string>();
lcpGraph.traverse(node => {
if (node.type === 'network') {
lcpGraphURLs.add(node.request.url);
}
});
const fcpGraphURLs = new Set<string>();
fcpGraph.traverse(node => {
if (node.type === 'network') {
fcpGraphURLs.add(node.request.url);
}
});
const origins = candidateRequestsByOrigin(parsedTrace, mainResource, contextRequests, lcpGraphURLs);
let maxWastedLcp = Types.Timing.Milli(0);
let maxWastedFcp = Types.Timing.Milli(0);
let preconnectCandidates: PreconnectCandidate[] = [];
origins.forEach(requests => {
const firstRequestOfOrigin = requests[0];
// Skip the origin if we don't have timing information
if (!firstRequestOfOrigin.args.data.timing) {
return;
}
const firstRequestOfOriginParsedURL = new Common.ParsedURL.ParsedURL(firstRequestOfOrigin.args.data.url);
const origin = firstRequestOfOriginParsedURL.securityOrigin();
// Approximate the connection time with the duration of TCP (+potentially SSL) handshake
// DNS time can be large but can also be 0 if a commonly used origin that's cached, so make
// no assumption about DNS.
const additionalRtt = additionalRttByOrigin.get(origin) ?? 0;
let connectionTime = Types.Timing.Milli(rtt + additionalRtt);
// TCP Handshake will be at least 2 RTTs for TLS connections
if (firstRequestOfOriginParsedURL.scheme === 'https') {
connectionTime = Types.Timing.Milli(connectionTime * 2);
}
const timeBetweenMainResourceAndDnsStart = Types.Timing.Micro(
firstRequestOfOrigin.args.data.syntheticData.sendStartTime - mainResource.args.data.syntheticData.finishTime +
Helpers.Timing.milliToMicro(firstRequestOfOrigin.args.data.timing.dnsStart));
const wastedMs =
Math.min(connectionTime, Helpers.Timing.microToMilli(timeBetweenMainResourceAndDnsStart)) as Types.Timing.Milli;
if (wastedMs < IGNORE_THRESHOLD_IN_MILLISECONDS) {
return;
}
maxWastedLcp = Math.max(wastedMs, maxWastedLcp) as Types.Timing.Milli;
if (fcpGraphURLs.has(firstRequestOfOrigin.args.data.url)) {
maxWastedFcp = Math.max(wastedMs, maxWastedFcp) as Types.Timing.Milli;
}
preconnectCandidates.push({
origin,
wastedMs,
});
});
preconnectCandidates = preconnectCandidates.sort((a, b) => b.wastedMs - a.wastedMs);
return preconnectCandidates.slice(0, TOO_MANY_PRECONNECTS_THRESHOLD);
}
export function generateInsight(
parsedTrace: Handlers.Types.ParsedTrace, context: InsightSetContext): NetworkDependencyTreeInsightModel {
if (!context.navigation) {
return finalize({
rootNodes: [],
maxTime: 0 as Types.Timing.Micro,
fail: false,
preconnectedOrigins: [],
preconnectCandidates: [],
});
}
const {
rootNodes,
maxTime,
fail,
relatedEvents,
} = generateNetworkDependencyTree(context);
const isWithinContext = (event: Types.Events.Event): boolean => Helpers.Timing.eventIsInBounds(event, context.bounds);
const contextRequests = parsedTrace.NetworkRequests.byTime.filter(isWithinContext);
const preconnectCandidates = generatePreconnectCandidates(parsedTrace, context, contextRequests);
const preconnectedOrigins = generatePreconnectedOrigins(parsedTrace, context, contextRequests, preconnectCandidates);
return finalize({
rootNodes,
maxTime,
fail,
relatedEvents,
preconnectedOrigins,
preconnectCandidates,
});
}