From 157f8a4f3cd79b40111edde44ba43c8ce170f9fd Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 16 Apr 2026 10:34:19 +0200 Subject: [PATCH 1/6] [docs-infra] Move type formatting into LS worker, parallelize validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three performance changes that reduce validate --types runtime by ~5.4×: 1. In-worker formatting: formatComponentData/formatHookData/etc now run inside the LS worker thread (processTypes.ts) instead of the main validate-worker thread. This avoids serializing raw allTypes AST nodes (45–50 MB per chart component) across the Unix socket — only the compact formatted TypesMeta (~500 KB) crosses the wire. 2. Parallel LS workers: WorkerThreadTypesProcessor now runs processTypes directly in-thread instead of routing through a single shared socket server. Each validate worker gets its own TS language service via the globalThis singleton in createOptimizedProgram. N workers = N parallel LS instances. Worker count capped at 4 with staggered spawns to avoid Node's BuiltinLoader rwlock contention on macOS. 3. Bug fixes: - waitForSocketFile: replaced fs.watch with polling on Unix — macOS FSEvents unreliably delivers filename in watch callbacks, causing 30s timeouts. - removePrefixFromHighlightedNodes: recurse into nested elements instead of breaking, fixing "type _ = " prefix leaking into output. Benchmarked on 107 chart files (mui-x): Before: 1313s wall, 3479s CPU, 299% avg After: 245s wall, 705s CPU, 306% avg --- packages/docs-infra/src/cli/runValidate.ts | 9 +- .../loadServerTypesMeta.ts | 197 ++---------------- .../loadServerTypesMeta/processTypes.ts | 166 ++++++++++++++- .../loadServerTypesMeta/socketClient.ts | 41 ++-- .../loadServerTypesMeta/workerManager.ts | 87 +------- .../removePrefixFromHighlightedNodes.ts | 66 ++---- 6 files changed, 225 insertions(+), 341 deletions(-) diff --git a/packages/docs-infra/src/cli/runValidate.ts b/packages/docs-infra/src/cli/runValidate.ts index b44f9574d..c759d6862 100644 --- a/packages/docs-infra/src/cli/runValidate.ts +++ b/packages/docs-infra/src/cli/runValidate.ts @@ -148,13 +148,18 @@ const runValidate: CommandModule<{}, Args> = { const updatedFilePaths: string[] = []; // === Create worker pool === - const workerCount = Math.max(1, availableParallelism() - 1); + const workerCount = Math.min(4, Math.max(1, availableParallelism() - 1)); const currentDir = path.dirname(fileURLToPath(import.meta.url)); const workerPath = path.join(currentDir, 'validateWorker.mjs'); const workers: Worker[] = []; for (let i = 0; i < workerCount; i += 1) { - workers.push(new Worker(workerPath)); + const w = new Worker(workerPath); + workers.push(w); + // Stagger: wait for online before spawning next to avoid + // Node's BuiltinLoader rwlock contention on concurrent bootstrap. + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => w.once('online', resolve)); } // Round-robin task distribution with promise tracking diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts index f4974a68a..327603f15 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts @@ -7,29 +7,18 @@ import { parseImportsAndComments, extractNameAndSlugFromUrl } from '../loaderUti import { nameMark, performanceMeasure } from '../loadPrecomputedCodeHighlighter/performanceLogger'; import { loadTypescriptConfig } from './loadTypescriptConfig'; import { resolveLibrarySourceFiles } from './resolveLibrarySourceFiles'; -import { ClassTypeMeta as ClassType, formatClassData, isPublicClass } from './formatClass'; -import { - ComponentTypeMeta as ComponentType, - formatComponentData, - isPublicComponent, -} from './formatComponent'; -import { HookTypeMeta as HookType, formatHookData, isPublicHook } from './formatHook'; -import { - FunctionTypeMeta as FunctionType, - formatFunctionData, - isPublicFunction, -} from './formatFunction'; -import { RawTypeMeta as RawType, formatRawData, type ReExportInfo } from './formatRaw'; +import { ClassTypeMeta as ClassType } from './formatClass'; +import { ComponentTypeMeta as ComponentType } from './formatComponent'; +import { HookTypeMeta as HookType } from './formatHook'; +import { FunctionTypeMeta as FunctionType } from './formatFunction'; +import { RawTypeMeta as RawType, type ReExportInfo } from './formatRaw'; import { type FormattedProperty, type FormattedEnumMember, type FormattedParameter, type FormatInlineTypeOptions, type DescriptionReplacement, - prettyFormat, } from './format'; -import { buildTypeCompatibilityMap, type TypeRewriteContext } from './rewriteTypes'; -import type { ExternalTypeMeta, ExternalTypesCollector } from './externalTypes'; import { findMetaFiles } from './findMetaFiles'; import { getWorkerManager } from './workerManager'; import { reconstructPerformanceLogs } from './performanceTracking'; @@ -341,6 +330,10 @@ export async function loadServerTypesMeta( dependencies: config.dependencies, rootContextDir, relativePath, + formattingOptions, + descriptionReplacements: options.descriptionReplacements, + externalTypesPattern: options.externalTypesPattern, + ordering: options.ordering, }); if (!workerResult.success) { @@ -361,163 +354,11 @@ export async function loadServerTypesMeta( true, ); - const rawVariantData = workerResult.variantData || {}; + // Worker returns pre-formatted TypesMeta[] per variant; raw AST nodes + // no longer cross the IPC boundary. + const variantData = workerResult.variantData || {}; const allDependencies = workerResult.allDependencies || []; - // Format the raw exports from the worker into TypesMeta - const variantData: Record }> = - {}; - - // Create external types collector — shared across all formatting calls. - // External types are collected during formatting as type names are encountered - // in the formatted output, eliminating the need for a separate tree walk + filtering step. - const collectedExternalTypes = new Map(); - - // Parse external types pattern once if provided - const externalTypesPatternRegex = options.externalTypesPattern - ? new RegExp(options.externalTypesPattern) - : undefined; - - // Build type compatibility map once from all exports across all variants - // This map is used to rewrite type references (e.g., Dialog.Trigger.State -> AlertDialog.Trigger.State) - const allRawExports = Object.values(rawVariantData).flatMap((v) => v.allTypes); - const allExportNames = Array.from(new Set(allRawExports.map((exp) => exp.name))); - const typeCompatibilityMap = buildTypeCompatibilityMap(allRawExports, allExportNames); - - // Build merged typeNameMap from all variants for type string rewriting - // typeNameMap maps flat names like "AlertDialogTriggerState" to dotted names like "AlertDialog.Trigger.State" - const mergedTypeNameMapForRewrite: Record = {}; - for (const variant of Object.values(rawVariantData)) { - if (variant.typeNameMap) { - Object.assign(mergedTypeNameMapForRewrite, variant.typeNameMap); - } - } - - const rewriteContext: TypeRewriteContext = { - typeCompatibilityMap, - exportNames: allExportNames, - typeNameMap: - Object.keys(mergedTypeNameMapForRewrite).length > 0 ? mergedTypeNameMapForRewrite : undefined, - }; - - // Process all variants in parallel - await Promise.all( - Object.entries(rawVariantData).map(async ([variantName, variantResult]) => { - // Create a per-variant external types collector. - // Each variant shares the same collected map so types are deduplicated automatically. - const externalTypesCollector: ExternalTypesCollector = { - collected: collectedExternalTypes, - allExports: variantResult.allTypes, - pattern: externalTypesPatternRegex, - typeNameMap: variantResult.typeNameMap, - }; - - // Process all exports in parallel within each variant - const types = await Promise.all( - variantResult.exports.map(async (exportNode): Promise => { - if (isPublicComponent(exportNode)) { - const formattedData = await formatComponentData( - exportNode, - variantResult.allTypes, - variantResult.typeNameMap || {}, - rewriteContext, - { - formatting: formattingOptions, - externalTypes: externalTypesCollector, - ordering: options.ordering, - descriptionReplacements: options.descriptionReplacements, - }, - ); - - return { - type: 'component', - name: exportNode.name, - data: formattedData, - }; - } - - if (isPublicHook(exportNode)) { - const formattedData = await formatHookData( - exportNode, - variantResult.typeNameMap || {}, - rewriteContext, - { - formatting: formattingOptions, - externalTypes: externalTypesCollector, - descriptionReplacements: options.descriptionReplacements, - }, - ); - - return { - type: 'hook', - name: exportNode.name, - data: formattedData, - }; - } - - if (isPublicFunction(exportNode)) { - const formattedData = await formatFunctionData( - exportNode, - variantResult.typeNameMap || {}, - rewriteContext, - { - formatting: formattingOptions, - externalTypes: externalTypesCollector, - descriptionReplacements: options.descriptionReplacements, - }, - ); - - return { - type: 'function', - name: exportNode.name, - data: formattedData, - }; - } - - if (isPublicClass(exportNode)) { - const formattedData = await formatClassData( - exportNode, - variantResult.typeNameMap || {}, - rewriteContext, - { - formatting: formattingOptions, - externalTypes: externalTypesCollector, - descriptionReplacements: options.descriptionReplacements, - }, - ); - - return { - type: 'class', - name: exportNode.name, - data: formattedData, - }; - } - - // For all other types (type aliases, interfaces, enums), format as raw - const formattedData = await formatRawData( - exportNode, - exportNode.name, - variantResult.typeNameMap || {}, - rewriteContext, - { - formatting: formattingOptions, - externalTypes: externalTypesCollector, - descriptionReplacements: options.descriptionReplacements, - }, - ); - - return { - type: 'raw', - name: exportNode.name, - data: formattedData, - }; - }), - ); - - variantData[variantName] = { types, typeNameMap: variantResult.typeNameMap }; - }), - ); - // Group types by component name when there's a single Default variant with sub-components // This creates per-component groupings (e.g., "Accordion.Root", "Accordion.Header") // For multi-variant cases (CssModules, Tailwind), keep the original structure @@ -810,19 +651,7 @@ export async function loadServerTypesMeta( // Get typeNameMap from first variant (they should all be the same) const typeNameMap = Object.values(variantData)[0]?.typeNameMap; - // External types were collected during formatting — no separate filtering needed. - // The collection happens in formatType() which only encounters types that appear - // in the formatted output, so every collected type is actually referenced. - - // Convert collected external types to a simple Record, formatted with prettier. - // Store the full declaration (e.g., `type NAME = ...;`) so generateTypesMarkdown uses it as-is. - const externalTypes: Record = {}; - await Promise.all( - Array.from(collectedExternalTypes.entries()).map(async ([name, meta]) => { - const formatted = await prettyFormat(meta.definition, name); - externalTypes[name] = formatted.trimEnd(); - }), - ); + const externalTypes = workerResult.externalTypes || {}; performanceMeasure( currentMark, diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts index aee2986d6..c797baa35 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts @@ -12,6 +12,13 @@ import ts from 'typescript'; import { createOptimizedProgram } from './createOptimizedProgram'; import { PerformanceTracker, type PerformanceLog } from './performanceTracking'; import { nameMark } from '../loadPrecomputedCodeHighlighter/performanceLogger'; +import { formatClassData, isPublicClass } from './formatClass'; +import { formatComponentData, isPublicComponent } from './formatComponent'; +import { formatHookData, isPublicHook } from './formatHook'; +import { formatFunctionData, isPublicFunction } from './formatFunction'; +import { formatRawData } from './formatRaw'; +import { prettyFormat } from './format'; +import { buildTypeCompatibilityMap } from './rewriteTypes'; /** * Extracts text content from a JSDoc description array. @@ -308,6 +315,10 @@ export interface WorkerRequest { /** Root context directory path (must end with /) */ rootContextDir: string; relativePath: string; + formattingOptions?: any; + descriptionReplacements?: any; + externalTypesPattern?: string; + ordering?: any; } export interface WorkerResponse { @@ -316,6 +327,7 @@ export interface WorkerResponse { variantData?: Record; /** All dependencies as filesystem paths (from TypeScript's program.getSourceFiles()) */ allDependencies?: string[]; + externalTypes?: Record; performanceLogs?: PerformanceLog[]; error?: string; debug?: { @@ -495,13 +507,13 @@ export async function processTypes(request: WorkerRequest): Promise = {}; + const rawVariantData: Record = {}; const allDependencies: string[] = []; const debugInfo: Record = {}; for (const result of variantResults) { if (result) { - variantData[result.variantName] = result.variantData; + rawVariantData[result.variantName] = result.variantData; result.dependencies.forEach((file: string) => { allDependencies.push(file); }); @@ -511,12 +523,158 @@ export async function processTypes(request: WorkerRequest): Promise(); + const externalTypesPatternRegex = request.externalTypesPattern + ? new RegExp(request.externalTypesPattern) + : undefined; + const allRawExports = Object.values(rawVariantData).flatMap((v) => v.allTypes); + const allExportNames = Array.from(new Set(allRawExports.map((exp) => exp.name))); + const typeCompatibilityMap = buildTypeCompatibilityMap(allRawExports, allExportNames); + const mergedTypeNameMapForRewrite: Record = {}; + for (const variant of Object.values(rawVariantData)) { + if (variant.typeNameMap) { + Object.assign(mergedTypeNameMapForRewrite, variant.typeNameMap); + } + } + const rewriteContext = { + typeCompatibilityMap, + exportNames: allExportNames, + typeNameMap: + Object.keys(mergedTypeNameMapForRewrite).length > 0 + ? mergedTypeNameMapForRewrite + : undefined, + }; + + const formattedVariantData: Record = {}; + await Promise.all( + Object.entries(rawVariantData).map(async ([variantName, variantResult]) => { + const externalTypesCollector = { + collected: collectedExternalTypes, + allExports: variantResult.allTypes, + pattern: externalTypesPatternRegex, + typeNameMap: variantResult.typeNameMap, + }; + const types = await Promise.all( + variantResult.exports.map(async (exportNode) => { + if (isPublicComponent(exportNode)) { + return { + type: 'component' as const, + name: exportNode.name, + data: await formatComponentData( + exportNode, + variantResult.allTypes, + variantResult.typeNameMap || {}, + rewriteContext, + { + formatting: request.formattingOptions, + externalTypes: externalTypesCollector, + ordering: request.ordering, + descriptionReplacements: request.descriptionReplacements, + }, + ), + }; + } + if (isPublicHook(exportNode)) { + return { + type: 'hook' as const, + name: exportNode.name, + data: await formatHookData( + exportNode, + variantResult.typeNameMap || {}, + rewriteContext, + { + formatting: request.formattingOptions, + externalTypes: externalTypesCollector, + descriptionReplacements: request.descriptionReplacements, + }, + ), + }; + } + if (isPublicFunction(exportNode)) { + return { + type: 'function' as const, + name: exportNode.name, + data: await formatFunctionData( + exportNode, + variantResult.typeNameMap || {}, + rewriteContext, + { + formatting: request.formattingOptions, + externalTypes: externalTypesCollector, + descriptionReplacements: request.descriptionReplacements, + }, + ), + }; + } + if (isPublicClass(exportNode)) { + return { + type: 'class' as const, + name: exportNode.name, + data: await formatClassData( + exportNode, + variantResult.typeNameMap || {}, + rewriteContext, + { + formatting: request.formattingOptions, + externalTypes: externalTypesCollector, + descriptionReplacements: request.descriptionReplacements, + }, + ), + }; + } + return { + type: 'raw' as const, + name: exportNode.name, + data: await formatRawData( + exportNode, + exportNode.name, + variantResult.typeNameMap || {}, + rewriteContext, + { + formatting: request.formattingOptions, + externalTypes: externalTypesCollector, + descriptionReplacements: request.descriptionReplacements, + }, + ), + }; + }), + ); + formattedVariantData[variantName] = { + types, + typeNameMap: variantResult.typeNameMap, + }; + }), + ); + + const externalTypes: Record = {}; + await Promise.all( + Array.from(collectedExternalTypes.entries()).map(async ([name, meta]) => { + const formatted = await prettyFormat(meta.definition, name); + externalTypes[name] = formatted.trimEnd(); + }), + ); + + const formatEnd = tracker.mark( + nameMark(functionName, 'Format End', [request.relativePath], true), + ); + tracker.measure( + nameMark(functionName, 'Format', [request.relativePath], true), + formatStart, + formatEnd, + ); + const serializedVariantData = stripFunctions(formattedVariantData); return { success: true, variantData: serializedVariantData, + externalTypes, allDependencies, performanceLogs: tracker.getLogs(), debug: Object.keys(debugInfo).length > 0 ? debugInfo[Object.keys(debugInfo)[0]] : undefined, diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/socketClient.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/socketClient.ts index a07686790..c21cc997f 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/socketClient.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/socketClient.ts @@ -9,7 +9,6 @@ */ import { connect, Socket } from 'node:net'; -import { watch } from 'node:fs'; import { mkdir, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -149,35 +148,21 @@ export async function waitForSocketFile( throw new Error(`Named pipe did not become available within ${timeoutMs}ms`); } - // Unix: Check if socket file already exists - if (await fileExists(socketPath)) { - return; - } - - // Ensure the directory exists before watching + // Unix: poll for file existence. fs.watch on macOS is unreliable for + // socket-file creation events (filename can be null, events missed). const dir = getEffectiveSocketDir(socketDir); await mkdir(dir, { recursive: true }); - - await new Promise((resolve, reject) => { - let timer: NodeJS.Timeout; - - // Watch the directory for the socket file to appear - const watcher = watch(dir, (eventType, filename) => { - if ( - filename && - (filename.includes('types.sock') || (isWindows && filename.includes('types'))) - ) { - clearTimeout(timer); - watcher.close(); - resolve(); - } - }); - - timer = setTimeout(() => { - watcher.close(); - reject(new Error(`Socket file did not appear within ${timeoutMs}ms`)); - }, timeoutMs); - }); + const pollInterval = 50; + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + // eslint-disable-next-line no-await-in-loop + if (await fileExists(socketPath)) { + return; + } + // eslint-disable-next-line no-await-in-loop + await sleep(pollInterval); + } + throw new Error(`Socket file did not appear within ${timeoutMs}ms`); } // Store the release function globally so we can call it when needed diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/workerManager.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/workerManager.ts index 526513fdb..75ccc5e19 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/workerManager.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/workerManager.ts @@ -5,12 +5,6 @@ import path from 'path'; import { fileURLToPath } from 'url'; // eslint-disable-next-line n/prefer-node-protocol import { isMainThread, Worker } from 'worker_threads'; -import { - SocketClient, - tryAcquireServerLock, - releaseServerLock, - waitForSocketFile, -} from './socketClient'; import type { WorkerRequest, WorkerResponse } from './worker'; /** @@ -108,89 +102,26 @@ class TypesMetaWorkerManager implements TypesProcessor { } } -/** - * Types processor for validate worker threads. - * On first processTypes() call, races to acquire the server lock: - * - Winner: releases the lock, spawns a bare worker (which acquires the lock - * naturally and becomes the socket server via existing worker.ts logic) - * - Losers: skip spawning - * - * All workers then connect to the socket server as clients. - * Result: N validate workers + 1 types server worker = N+1 threads total. - */ class WorkerThreadTypesProcessor implements TypesProcessor { - private socketDir: string | undefined; + private socketDir?: string; - private initPromise: Promise | null = null; - - private socketClient: SocketClient | null = null; - - private serverWorker: Worker | null = null; + private processTypesFn: ((request: WorkerRequest) => Promise) | null = null; constructor(socketDir?: string) { this.socketDir = socketDir; } - private ensureInit(): Promise { - if (!this.initPromise) { - this.initPromise = this.init().catch((error) => { - // Reset so the next processTypes() call can retry - this.initPromise = null; - throw error; - }); - } - return this.initPromise; - } - - private async init(): Promise { - const isServer = await tryAcquireServerLock(this.socketDir); - - if (isServer) { - // We won the lock — spawn the bare worker which will become a socket server. - // Keep the lock held so no other worker tries to spawn a second server. - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const workerPath = path.join(currentDir, 'worker.mjs'); - this.serverWorker = new Worker(workerPath, { - workerData: { isServer: true, ...(this.socketDir && { socketDir: this.socketDir }) }, - }); - - this.serverWorker.on('error', (error) => { - console.error('[WorkerThreadTypesProcessor] Server worker error:', error); - }); - - try { - // Wait for the socket file to appear, then release the lock. - await waitForSocketFile(this.socketDir, 30_000); - } catch (error) { - // Server worker crashed before creating the socket — release the lock - // so another worker can become the server on a subsequent attempt. - await releaseServerLock(); - throw error; - } - await releaseServerLock(); - } else { - // Another worker is the server — wait for the socket file. - await waitForSocketFile(this.socketDir, 30_000); - } - - this.socketClient = new SocketClient(this.socketDir); - await this.socketClient.connect(); - } - async processTypes(request: WorkerRequest): Promise { - await this.ensureInit(); - return this.socketClient!.sendRequest(request); + if (!this.processTypesFn) { + const mod = await import('./processTypes'); + this.processTypesFn = mod.processTypes; + } + return this.processTypesFn(request); } terminate(): void { - if (this.socketClient) { - this.socketClient.close(); - this.socketClient = null; - } - if (this.serverWorker) { - this.serverWorker.terminate(); - this.serverWorker = null; - } + // No external resources — LS singleton in globalThis is cleaned + // up when the worker thread exits. } } diff --git a/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.ts b/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.ts index 865ad7da1..440c6294f 100644 --- a/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.ts +++ b/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.ts @@ -3,76 +3,52 @@ import type { Element, ElementContent } from 'hast'; /** * Removes a prefix from the beginning of highlighted HAST nodes. * - * This function is used after syntax highlighting to remove temporary prefix text - * that was added to provide context for the highlighter. The prefix may span across - * multiple text nodes and element boundaries. - * - * @param children - The array of HAST nodes to modify - * @param prefixLength - The number of characters to remove from the beginning - * - * @example - * // Remove "type _ = " prefix from highlighted type - * const nodes = [ - * { type: 'element', children: [{ type: 'text', value: 'type _ = string' }] } - * ]; - * removePrefixFromHighlightedNodes(nodes, 9); - * // Result: [{ type: 'element', children: [{ type: 'text', value: 'string' }] }] + * Used after syntax highlighting to strip temporary prefix text (e.g. `type _ = `) + * that was added to give the highlighter valid TypeScript. The prefix may span + * across multiple text nodes AND across arbitrarily-nested element boundaries, + * so this walks recursively and returns the number of characters it consumed + * so callers can coordinate. */ export function removePrefixFromHighlightedNodes( children: ElementContent[], prefixLength: number, -): void { +): number { let removedLength = 0; - // Remove nodes/text until we've removed the full prefix while (removedLength < prefixLength && children.length > 0) { const firstChild = children[0]; if (firstChild.type === 'text') { const textLength = firstChild.value.length; if (removedLength + textLength <= prefixLength) { - // Remove entire text node children.shift(); removedLength += textLength; } else { - // Remove part of text node const charsToRemove = prefixLength - removedLength; firstChild.value = firstChild.value.slice(charsToRemove); removedLength = prefixLength; } } else if (firstChild.type === 'element') { - // For elements, we need to recurse into their children const element = firstChild as Element; - if (element.children && element.children.length > 0) { - const firstElementChild = element.children[0]; - if (firstElementChild.type === 'text') { - const textLength = firstElementChild.value.length; - if (removedLength + textLength <= prefixLength) { - // Remove entire text node - element.children.shift(); - removedLength += textLength; - // If element is now empty, remove it too - if (element.children.length === 0) { - children.shift(); - } - } else { - // Remove part of text node - const charsToRemove = prefixLength - removedLength; - firstElementChild.value = firstElementChild.value.slice(charsToRemove); - removedLength = prefixLength; - } - } else { - // If first child isn't text, we can't easily handle this - // Just stop trying to remove prefix - break; - } - } else { - // Empty element, remove it + if (!element.children || element.children.length === 0) { children.shift(); + continue; + } + const consumed = removePrefixFromHighlightedNodes( + element.children as ElementContent[], + prefixLength - removedLength, + ); + removedLength += consumed; + if (element.children.length === 0) { + children.shift(); + } else if (consumed === 0) { + // Defensive: nothing removable here, stop rather than spin. + break; } } else { - // Unknown node type, stop trying to remove prefix break; } } + + return removedLength; } From f4522a6779f98478bff59f4d8dd07ff6d21ba882 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 16 Apr 2026 10:46:37 +0200 Subject: [PATCH 2/6] Restore original workerCount (no cap at 4) --- packages/docs-infra/src/cli/runValidate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs-infra/src/cli/runValidate.ts b/packages/docs-infra/src/cli/runValidate.ts index c759d6862..98ecb3caa 100644 --- a/packages/docs-infra/src/cli/runValidate.ts +++ b/packages/docs-infra/src/cli/runValidate.ts @@ -148,7 +148,7 @@ const runValidate: CommandModule<{}, Args> = { const updatedFilePaths: string[] = []; // === Create worker pool === - const workerCount = Math.min(4, Math.max(1, availableParallelism() - 1)); + const workerCount = Math.max(1, availableParallelism() - 1); const currentDir = path.dirname(fileURLToPath(import.meta.url)); const workerPath = path.join(currentDir, 'validateWorker.mjs'); From 22f6aa36c226f089073a1676c7b824b207bb0172 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 16 Apr 2026 10:48:37 +0200 Subject: [PATCH 3/6] Remove worker spawn stagger --- packages/docs-infra/src/cli/runValidate.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/docs-infra/src/cli/runValidate.ts b/packages/docs-infra/src/cli/runValidate.ts index 98ecb3caa..b44f9574d 100644 --- a/packages/docs-infra/src/cli/runValidate.ts +++ b/packages/docs-infra/src/cli/runValidate.ts @@ -154,12 +154,7 @@ const runValidate: CommandModule<{}, Args> = { const workers: Worker[] = []; for (let i = 0; i < workerCount; i += 1) { - const w = new Worker(workerPath); - workers.push(w); - // Stagger: wait for online before spawning next to avoid - // Node's BuiltinLoader rwlock contention on concurrent bootstrap. - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => w.once('online', resolve)); + workers.push(new Worker(workerPath)); } // Round-robin task distribution with promise tracking From f3425857236ee4c2e6693dbfa4a1a9cbcec6a7f9 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 16 Apr 2026 10:55:37 +0200 Subject: [PATCH 4/6] Fix type errors in processTypes and loadServerTypesMeta --- .../loadServerTypesMeta/processTypes.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts index c797baa35..db41c1708 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/processTypes.ts @@ -19,6 +19,8 @@ import { formatFunctionData, isPublicFunction } from './formatFunction'; import { formatRawData } from './formatRaw'; import { prettyFormat } from './format'; import { buildTypeCompatibilityMap } from './rewriteTypes'; +import type { ExternalTypeMeta, ExternalTypesCollector } from './externalTypes'; +import type { BaseTypeMeta } from '../loadServerTypesText/organizeTypesByExport'; /** * Extracts text content from a JSDoc description array. @@ -292,12 +294,18 @@ function extractNamespaces(exports: ExportNode[]): string[] { return Array.from(namespaces); } -// Worker returns raw export nodes and metadata for formatting in main thread -export interface VariantResult { +// Raw variant data before formatting (internal to processTypes) +interface RawVariantResult { exports: ExportNode[]; - allTypes: ExportNode[]; // All exports including internal types for reference resolution + allTypes: ExportNode[]; namespaces: string[]; - typeNameMap?: Record; // Maps flat type names to dotted names (serializable across worker boundary) + typeNameMap?: Record; +} + +// Formatted variant data returned across the wire +export interface VariantResult { + types: BaseTypeMeta[]; + typeNameMap?: Record; } export interface WorkerRequest { @@ -507,7 +515,7 @@ export async function processTypes(request: WorkerRequest): Promise = {}; + const rawVariantData: Record = {}; const allDependencies: string[] = []; const debugInfo: Record = {}; @@ -530,7 +538,7 @@ export async function processTypes(request: WorkerRequest): Promise(); + const collectedExternalTypes = new Map(); const externalTypesPatternRegex = request.externalTypesPattern ? new RegExp(request.externalTypesPattern) : undefined; @@ -555,7 +563,7 @@ export async function processTypes(request: WorkerRequest): Promise = {}; await Promise.all( Object.entries(rawVariantData).map(async ([variantName, variantResult]) => { - const externalTypesCollector = { + const externalTypesCollector: ExternalTypesCollector = { collected: collectedExternalTypes, allExports: variantResult.allTypes, pattern: externalTypesPatternRegex, From 955ed7b375818f25c86cf1064ea89864b866bf26 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 16 Apr 2026 11:02:40 +0200 Subject: [PATCH 5/6] Cast variantData to TypesMeta for downstream type compat --- .../pipeline/loadServerTypesMeta/loadServerTypesMeta.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts index 327603f15..1f9695efe 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/loadServerTypesMeta.ts @@ -355,8 +355,13 @@ export async function loadServerTypesMeta( ); // Worker returns pre-formatted TypesMeta[] per variant; raw AST nodes - // no longer cross the IPC boundary. - const variantData = workerResult.variantData || {}; + // no longer cross the IPC boundary. Cast from VariantResult (BaseTypeMeta) + // to the narrow TypesMeta since processTypes produces the full discriminated + // union at runtime — the wire format just can't carry it statically. + const variantData = (workerResult.variantData || {}) as Record< + string, + { types: TypesMeta[]; typeNameMap?: Record } + >; const allDependencies = workerResult.allDependencies || []; // Group types by component name when there's a single Default variant with sub-components From 71f9cba46fc3b1776c70fd7cb2600518ab4e729d Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 16 Apr 2026 14:39:37 +0200 Subject: [PATCH 6/6] Update test for recursive removePrefixFromHighlightedNodes --- .../removePrefixFromHighlightedNodes.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.test.ts b/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.test.ts index 2055afc07..a2e349f26 100644 --- a/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.test.ts +++ b/packages/docs-infra/src/pipeline/transformHtmlCodeInline/removePrefixFromHighlightedNodes.test.ts @@ -155,7 +155,7 @@ describe('removePrefixFromHighlightedNodes', () => { expect(children).toHaveLength(0); }); - it('should stop at element with non-text first child', () => { + it('should recurse into nested elements to remove prefix', () => { const children: ElementContent[] = [ { type: 'element', @@ -173,11 +173,12 @@ describe('removePrefixFromHighlightedNodes', () => { { type: 'text', value: 'xyz' }, ]; - // Should stop when it encounters nested element as first child + // Should recurse through span > strong > text to remove 5 chars total removePrefixFromHighlightedNodes(children, 5); - // Cannot remove prefix from nested element structure, should leave as-is - expect(children).toHaveLength(2); + // 'abc' (3) removed from nested element, then 'xy' (2) from text node + expect(children).toHaveLength(1); + expect((children[0] as Text).value).toBe('z'); }); it('should handle mixed content types', () => {