diff --git a/.changeset/few-kings-sip.md b/.changeset/few-kings-sip.md new file mode 100644 index 000000000..438db2437 --- /dev/null +++ b/.changeset/few-kings-sip.md @@ -0,0 +1,29 @@ +--- +'@linaria/babel-preset': major +'@linaria/logger': major +'@linaria/rollup': major +'@linaria/testkit': major +'@linaria/utils': major +'@linaria/atomic': major +'@linaria/cli': major +'@linaria/core': major +'@linaria/esbuild': major +'@linaria/extractor': major +'@linaria/griffel': major +'@linaria/babel-plugin-interop': major +'linaria': major +'@linaria/postcss-linaria': major +'@linaria/react': major +'@linaria/server': major +'@linaria/shaker': major +'@linaria/stylelint': major +'@linaria/stylelint-config-standard-linaria': major +'@linaria/tags': major +'@linaria/vite': major +'@linaria/webpack-loader': major +'@linaria/webpack4-loader': major +'@linaria/webpack5-loader': major +'linaria-website': major +--- + +Rewritten dependecny tree processing with support for wildcard re-exports. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 59545bff6..84f3772f4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -208,7 +208,7 @@ module.exports = { return false; } - return /\b(?:export|import)\b/.test(code); + return /\b(?:export|import)\b/m.test(code); }, action: require.resolve('@linaria/shaker'), } diff --git a/examples/vite/.linariarc.mjs b/examples/vite/.linariarc.mjs index 392fa02dd..f25185848 100644 --- a/examples/vite/.linariarc.mjs +++ b/examples/vite/.linariarc.mjs @@ -13,7 +13,7 @@ module.exports = { return false; } - return /(?:^|\n|;)\s*(?:export|import)\s+/.test(code); + return /(?:^|\n|;)\s*(?:export|import)\s+/m.test(code); }, action: require.resolve('@linaria/shaker'), }, diff --git a/packages/babel/jest.config.js b/packages/babel/jest.config.js index e86e13bab..226065ecb 100644 --- a/packages/babel/jest.config.js +++ b/packages/babel/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], }; diff --git a/packages/babel/package.json b/packages/babel/package.json index 933cf170d..62be2bcf1 100644 --- a/packages/babel/package.json +++ b/packages/babel/package.json @@ -46,6 +46,7 @@ "@linaria/utils": "workspace:^", "cosmiconfig": "^8.0.0", "happy-dom": "10.8.0", + "nanoid": "^3.3.6", "source-map": "^0.7.3", "stylis": "^3.5.4" }, diff --git a/packages/babel/src/cache.ts b/packages/babel/src/cache.ts index 32ce0c9bd..ea3439766 100644 --- a/packages/babel/src/cache.ts +++ b/packages/babel/src/cache.ts @@ -4,16 +4,14 @@ import type { File } from '@babel/types'; import { linariaLogger } from '@linaria/logger'; -import type { IModule } from './module'; -import type { IEntrypoint } from './transform-stages/queue/types'; -import type { ITransformFileResult } from './types'; +import type { IBaseEntrypoint, IModule, ITransformFileResult } from './types'; function hashContent(content: string) { return createHash('sha256').update(content).digest('hex'); } interface ICaches { - entrypoints: Map; + entrypoints: Map; ignored: Map; resolve: Map; resolveTask: Map< @@ -62,7 +60,7 @@ const loggers = cacheNames.reduce( export class TransformCacheCollection { private contentHashes = new Map(); - protected readonly entrypoints: Map; + protected readonly entrypoints: Map; protected readonly ignored: Map; @@ -146,8 +144,12 @@ export class TransformCacheCollection { } public invalidate(cacheName: CacheNames, key: string): void { - loggers[cacheName]('invalidate', key); const cache = this[cacheName] as Map; + if (!cache.has(key)) { + return; + } + + loggers[cacheName]('invalidate', key); cache.delete(key); } diff --git a/packages/babel/src/index.ts b/packages/babel/src/index.ts index 960a7b753..4ad4fd378 100644 --- a/packages/babel/src/index.ts +++ b/packages/babel/src/index.ts @@ -8,19 +8,17 @@ import type { ConfigAPI, TransformCaller } from '@babel/core'; import { debug } from '@linaria/logger'; import transform from './plugins/babel-transform'; -import type { PluginOptions } from './transform-stages/helpers/loadLinariaOptions'; +import type { PluginOptions } from './types'; export { slugify } from '@linaria/utils'; export { default as preeval } from './plugins/preeval'; export { default as withLinariaMetadata } from './utils/withLinariaMetadata'; export { default as Module, DefaultModuleImplementation } from './module'; -export type { IModule } from './module'; export { default as transform } from './transform'; export * from './types'; export { parseFile } from './transform-stages/helpers/parseFile'; export { default as loadLinariaOptions } from './transform-stages/helpers/loadLinariaOptions'; -export type { PluginOptions } from './transform-stages/helpers/loadLinariaOptions'; export { prepareCode } from './transform-stages/queue/actions/transform'; export { createEntrypoint } from './transform-stages/queue/createEntrypoint'; export { transformUrl } from './transform-stages/4-extract'; diff --git a/packages/babel/src/module.ts b/packages/babel/src/module.ts index c1eb2f12f..e35832bb9 100644 --- a/packages/babel/src/module.ts +++ b/packages/babel/src/module.ts @@ -25,6 +25,7 @@ import type { StrictOptions } from '@linaria/utils'; import { getFileIdx } from '@linaria/utils'; import { TransformCacheCollection } from './cache'; +import type { IModule } from './types'; import createVmContext from './vm/createVmContext'; type HiddenModuleMembers = { @@ -94,14 +95,6 @@ const hasKey = ( obj !== null && key in obj; -export interface IModule { - debug: CustomDebug; - readonly exports: unknown; - readonly idx: number; - readonly isEvaluated: boolean; - readonly only: string; -} - function getUncached(cached: string | string[], test: string): string[] { if (cached === test) { return []; @@ -334,8 +327,9 @@ class Module { if (uncachedExports.length > 0 || !m.isEvaluated) { m.debug( 'eval-cache', - 'is going to be invalidated because %o is not evaluated yet', - uncachedExports + 'is going to be invalidated because %o is not evaluated yet (evaluated: %o)', + uncachedExports, + m.only ); this.cache.invalidate('eval', filename); @@ -379,8 +373,9 @@ class Module { log( 'code-cache', - '❌ file has been processed during prepare stage but %o is not evaluated yet', - uncachedExports + '❌ file has been processed during prepare stage but %o is not evaluated yet (evaluated: %o)', + uncachedExports, + cached?.only ?? [] ); } else { log('code-cache', '❌ file has not been processed during prepare stage'); diff --git a/packages/babel/src/plugins/babel-transform.ts b/packages/babel/src/plugins/babel-transform.ts index 3357c2c68..f5191d423 100644 --- a/packages/babel/src/plugins/babel-transform.ts +++ b/packages/babel/src/plugins/babel-transform.ts @@ -9,9 +9,8 @@ import type { Core } from '../babel'; import { TransformCacheCollection } from '../cache'; import { prepareForEvalSync } from '../transform-stages/1-prepare-for-eval'; import evalStage from '../transform-stages/2-eval'; -import type { PluginOptions } from '../transform-stages/helpers/loadLinariaOptions'; import loadLinariaOptions from '../transform-stages/helpers/loadLinariaOptions'; -import type { IPluginState } from '../types'; +import type { IPluginState, PluginOptions } from '../types'; import { processTemplateExpression } from '../utils/processTemplateExpression'; import withLinariaMetadata from '../utils/withLinariaMetadata'; diff --git a/packages/babel/src/plugins/preeval.ts b/packages/babel/src/plugins/preeval.ts index 3c5cb926b..48a64c383 100644 --- a/packages/babel/src/plugins/preeval.ts +++ b/packages/babel/src/plugins/preeval.ts @@ -41,36 +41,38 @@ export default function preeval( const rootScope = file.scope; this.processors = []; - const onProcessTemplateFinished = eventEmitter.pair({ - method: 'queue:transform:preeval:processTemplate', - }); - - file.path.traverse({ - Identifier: (p) => { - processTemplateExpression(p, file.opts, options, (processor) => { - processor.dependencies.forEach((dependency) => { - if (dependency.ex.type === 'Identifier') { - addIdentifierToLinariaPreval(rootScope, dependency.ex.name); - } - }); - - processor.doEvaltimeReplacement(); - this.processors.push(processor); - }); + eventEmitter.pair( + { + method: 'queue:transform:preeval:processTemplate', }, - }); - - onProcessTemplateFinished(); + () => { + file.path.traverse({ + Identifier: (p) => { + processTemplateExpression(p, file.opts, options, (processor) => { + processor.dependencies.forEach((dependency) => { + if (dependency.ex.type === 'Identifier') { + addIdentifierToLinariaPreval(rootScope, dependency.ex.name); + } + }); + + processor.doEvaltimeReplacement(); + this.processors.push(processor); + }); + }, + }); + } + ); if ( isFeatureEnabled(options.features, 'dangerousCodeRemover', filename) ) { log('start', 'Strip all JSX and browser related stuff'); - const onCodeRemovingFinished = eventEmitter.pair({ - method: 'queue:transform:preeval:removeDangerousCode', - }); - removeDangerousCode(file.path); - onCodeRemovingFinished(); + eventEmitter.pair( + { + method: 'queue:transform:preeval:removeDangerousCode', + }, + () => removeDangerousCode(file.path) + ); } onFinishCallbacks.set( diff --git a/packages/babel/src/transform-stages/1-prepare-for-eval.ts b/packages/babel/src/transform-stages/1-prepare-for-eval.ts index 8c2bab36f..7e4b5db85 100644 --- a/packages/babel/src/transform-stages/1-prepare-for-eval.ts +++ b/packages/babel/src/transform-stages/1-prepare-for-eval.ts @@ -5,8 +5,10 @@ import type { Core } from '../babel'; import type { TransformCacheCollection } from '../cache'; import type { ITransformFileResult, Options } from '../types'; -import { AsyncActionQueue, SyncActionQueue } from './helpers/ActionQueue'; +import { AsyncActionQueue, SyncActionQueue } from './queue/ActionQueue'; import { addToCodeCache } from './queue/actions/addToCodeCache'; +import { explodeReexports } from './queue/actions/explodeReexports'; +import { getExports } from './queue/actions/getExports'; import { processEntrypoint } from './queue/actions/processEntrypoint'; import { processImports } from './queue/actions/processImports'; import { @@ -27,16 +29,15 @@ export function prepareForEvalSync( options: Pick, eventEmitter = EventEmitter.dummy ): ITransformFileResult | undefined { + const services = { babel, cache, options, eventEmitter }; + const entrypoint = createEntrypoint( - babel, - rootLog, - cache, + services, + { log: rootLog }, partialEntrypoint.name, partialEntrypoint.only, partialEntrypoint.code, - pluginOptions, - options, - eventEmitter + pluginOptions ); if (entrypoint === 'ignored') { @@ -44,9 +45,11 @@ export function prepareForEvalSync( } const queue = new SyncActionQueue( - { babel, cache, options, eventEmitter }, + services, { addToCodeCache, + explodeReexports, + getExports, processEntrypoint, processImports, resolveImports: syncResolveImports.bind(null, resolve), @@ -79,6 +82,8 @@ export default async function prepareForEval( options: Pick, eventEmitter = EventEmitter.dummy ): Promise { + const services = { babel, cache, options, eventEmitter }; + /* * This method can be run simultaneously for multiple files. * A shared cache is accessible for all runs, but each run has its own queue @@ -88,15 +93,12 @@ export default async function prepareForEval( * the combined "only" option. */ const entrypoint = createEntrypoint( - babel, - rootLog, - cache, + services, + { log: rootLog }, partialEntrypoint.name, partialEntrypoint.only, partialEntrypoint.code, - pluginOptions, - options, - eventEmitter + pluginOptions ); if (entrypoint === 'ignored') { @@ -104,9 +106,11 @@ export default async function prepareForEval( } const queue = new AsyncActionQueue( - { babel, cache, options, eventEmitter }, + services, { addToCodeCache, + explodeReexports, + getExports, processEntrypoint, processImports, resolveImports: asyncResolveImports.bind(null, resolve), diff --git a/packages/babel/src/transform-stages/helpers/ActionQueue.ts b/packages/babel/src/transform-stages/helpers/ActionQueue.ts deleted file mode 100644 index 90534c724..000000000 --- a/packages/babel/src/transform-stages/helpers/ActionQueue.ts +++ /dev/null @@ -1,241 +0,0 @@ -// eslint-disable-next-line max-classes-per-file -import { relative, sep } from 'path'; - -import type { EventEmitter } from '@linaria/utils'; - -import type { Core } from '../../babel'; -import type { TransformCacheCollection } from '../../cache'; -import type { Options } from '../../types'; -import type { IBaseNode, Next as GenericNext } from '../queue/PriorityQueue'; -import { PriorityQueue } from '../queue/PriorityQueue'; -import type { - ActionQueueItem, - IEntrypoint, - IResolvedImport, - IResolveImportsAction, -} from '../queue/types'; - -const peek = (arr: T[]) => - arr.length > 0 ? arr[arr.length - 1] : undefined; - -export type Next = GenericNext; - -type Merger = (a: T, b: T, next: Next) => void; - -const reprocessEntrypoint: Merger = (a, b, next) => { - const entrypoint: IEntrypoint = { - ...a.entrypoint, - only: Array.from( - new Set([...a.entrypoint.only, ...b.entrypoint.only]) - ).sort(), - }; - - b.entrypoint.log('Superseded by %s', a.entrypoint.log.namespace); - - next({ - type: 'processEntrypoint', - entrypoint, - stack: a.stack, - refCount: (a.refCount ?? 1) + (b.refCount ?? 1), - }); -}; - -const mergers: { - [K in ActionQueueItem['type']]: Merger>; -} = { - processEntrypoint: reprocessEntrypoint, - resolveImports: (a, b, next) => { - const mergedImports = new Map(); - const addOrMerge = (v: string[], k: string) => { - const prev = mergedImports.get(k); - if (prev) { - mergedImports.set(k, Array.from(new Set([...prev, ...v])).sort()); - } else { - mergedImports.set(k, v); - } - }; - - a.imports?.forEach(addOrMerge); - b.imports?.forEach(addOrMerge); - const merged: IResolveImportsAction = { - ...a, - imports: mergedImports, - callback: (resolved) => { - a.callback?.(resolved); - b.callback?.(resolved); - }, - }; - - next(merged); - }, - processImports: (a, b, next) => { - const mergedResolved: IResolvedImport[] = []; - const addOrMerge = (v: IResolvedImport) => { - const prev = mergedResolved.find( - (i) => i.importedFile === v.importedFile - ); - if (prev) { - prev.importsOnly = Array.from( - new Set([...prev.importsOnly, ...v.importsOnly]) - ).sort(); - } else { - mergedResolved.push(v); - } - }; - - a.resolved.forEach(addOrMerge); - b.resolved.forEach(addOrMerge); - const merged = { - ...a, - resolved: mergedResolved, - }; - - next(merged); - }, - transform: reprocessEntrypoint, - addToCodeCache: (a, b, next) => { - const aOnly = a.entrypoint.only; - const bOnly = b.entrypoint.only; - if (aOnly.includes('*') || bOnly.every((i) => aOnly.includes(i))) { - next(a); - return; - } - - if (bOnly.includes('*') || aOnly.every((i) => bOnly.includes(i))) { - next(b); - return; - } - - reprocessEntrypoint(a, b, next); - }, -}; - -const weights: Record = { - addToCodeCache: 0, - processEntrypoint: 10, - resolveImports: 20, - processImports: 15, - transform: 5, -}; - -function hasLessPriority(a: ActionQueueItem, b: ActionQueueItem) { - if (a.type === b.type) { - const firstA = peek(a.stack); - const firstB = peek(b.stack); - if (a.refCount === b.refCount && firstA && firstB) { - const distanceA = relative(firstA, a.entrypoint.name).split(sep).length; - const distanceB = relative(firstB, b.entrypoint.name).split(sep).length; - return distanceA > distanceB; - } - - return (a.refCount ?? 1) > (b.refCount ?? 1); - } - - return weights[a.type] < weights[b.type]; -} - -const nameOf = (node: IBaseNode): string => - `${node.type}:${node.entrypoint.name}`; - -const keyOf = (node: IBaseNode): string => nameOf(node); - -function merge( - a: T, - b: ActionQueueItem, - next: Next -): void { - if (a.type === b.type) { - return (mergers[a.type] as (a: T, b: T, next: Next) => void)( - a, - b as T, - next - ); - } - - throw new Error(`Cannot merge ${nameOf(a)} with ${nameOf(b)}`); -} - -type Services = { - babel: Core; - cache: TransformCacheCollection; - options: Pick; - eventEmitter: EventEmitter; -}; - -type Handler = ( - services: Services, - action: Extract, - next: Next -) => TRes; - -class GenericActionQueue extends PriorityQueue { - constructor( - protected services: Services, - protected handlers: { - [K in ActionQueueItem['type']]: Handler; - }, - entrypoint: IEntrypoint - ) { - const log = entrypoint.log.extend('queue'); - - super(log, keyOf, merge, hasLessPriority); - - log('Created for entrypoint %s', entrypoint.name); - - this.enqueue({ - type: 'processEntrypoint', - entrypoint, - stack: [], - refCount: 1, - }); - } - - protected handle( - action: Extract - ): TRes { - const { eventEmitter } = this.services; - const handler = this.handlers[action.type] as Handler; - - eventEmitter.single({ - type: 'queue-action', - action: action.type, - file: action.entrypoint.name, - only: action.entrypoint.only.join(','), - }); - - return eventEmitter.autoPair( - { - method: `queue:${action.type}`, - }, - () => - handler(this.services, action, (item, refCount = 1) => - this.enqueue({ - ...item, - refCount, - } as ActionQueueItem) - ) - ); - } -} - -export class SyncActionQueue extends GenericActionQueue { - public runNext() { - const next = this.dequeue(); - if (!next) { - return; - } - - this.handle(next); - } -} - -export class AsyncActionQueue extends GenericActionQueue | void> { - public async runNext() { - const next = this.dequeue(); - if (!next) { - return; - } - - await this.handle(next); - } -} diff --git a/packages/babel/src/transform-stages/helpers/loadLinariaOptions.ts b/packages/babel/src/transform-stages/helpers/loadLinariaOptions.ts index 4bff92b73..cda0ccad8 100644 --- a/packages/babel/src/transform-stages/helpers/loadLinariaOptions.ts +++ b/packages/babel/src/transform-stages/helpers/loadLinariaOptions.ts @@ -2,12 +2,7 @@ import { cosmiconfigSync } from 'cosmiconfig'; import type { StrictOptions } from '@linaria/utils'; -import type { Stage } from '../../types'; - -export type PluginOptions = StrictOptions & { - configFile?: string | false; - stage?: Stage; -}; +import type { PluginOptions } from '../../types'; const searchPlaces = [ `.linariarc`, @@ -74,7 +69,7 @@ export default function loadLinariaOptions( } // If a file contains `export` or `import` keywords, we assume it's an ES-module - return /^(?:export|import)\b/.test(code); + return /^(?:export|import)\b/m.test(code); }, action: require.resolve('@linaria/shaker'), }, diff --git a/packages/babel/src/transform-stages/queue/ActionQueue.ts b/packages/babel/src/transform-stages/queue/ActionQueue.ts new file mode 100644 index 000000000..8bb4c0fd0 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/ActionQueue.ts @@ -0,0 +1,41 @@ +// eslint-disable-next-line max-classes-per-file +import { GenericActionQueue } from './GenericActionQueue'; +import type { IBaseServices } from './types'; + +export class SyncActionQueue< + TServices extends IBaseServices +> extends GenericActionQueue { + public runNext() { + const next = this.dequeue(); + if (!next) { + return; + } + + next.entrypoint.log('Start %s from %r', next.type, this.logRef); + this.handle(next); + next.entrypoint.log('Finish %s from %r', next.type, this.logRef); + } +} + +export class AsyncActionQueue< + TServices extends IBaseServices +> extends GenericActionQueue | void, TServices> { + public runNext(): Promise | void { + const next = this.dequeue(); + if (!next) { + return Promise.resolve(); + } + + next.entrypoint.log('Start %s from %r', next.type, this.logRef); + const result = this.handle(next); + const log = () => + next.entrypoint.log('Finish %s from %r', next.type, this.logRef); + if (result instanceof Promise) { + result.then(log, () => {}); + } else { + log(); + } + + return result; + } +} diff --git a/packages/babel/src/transform-stages/queue/GenericActionQueue.ts b/packages/babel/src/transform-stages/queue/GenericActionQueue.ts new file mode 100644 index 000000000..98fd7ac38 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/GenericActionQueue.ts @@ -0,0 +1,161 @@ +import { relative, sep } from 'path'; + +import type { Debugger } from '@linaria/logger'; + +import type { IBaseEntrypoint } from '../../types'; + +import { PriorityQueue } from './PriorityQueue'; +import { createAction, getRefsCount, keyOf } from './actions/action'; +import { actionRunner } from './actions/actionRunner'; +import type { + DataOf, + ActionByType, + IBaseAction, + ActionQueueItem, + IBaseServices, + Handler, +} from './types'; + +const weights: Record = { + addToCodeCache: 0, + transform: 5, + explodeReexports: 10, + processEntrypoint: 15, + processImports: 20, + getExports: 25, + resolveImports: 30, +}; + +function hasLessPriority(a: IBaseAction, b: IBaseAction) { + if (a.type === b.type) { + const parentA = a.entrypoint.parent?.name; + const parentB = b.entrypoint.parent?.name; + const refCountA = getRefsCount(a.entrypoint); + const refCountB = getRefsCount(b.entrypoint); + if (refCountA === refCountB && parentA && parentB) { + const distanceA = relative(parentA, a.entrypoint.name).split(sep).length; + const distanceB = relative(parentB, b.entrypoint.name).split(sep).length; + return distanceA > distanceB; + } + + return refCountA > refCountB; + } + + return weights[a.type] < weights[b.type]; +} + +export type Handlers = { + [K in ActionQueueItem['type']]: Handler, TRes>; +}; + +export class GenericActionQueue< + TRes, + TServices extends IBaseServices +> extends PriorityQueue { + protected readonly queueIdx: string; + + protected readonly log: Debugger; + + protected readonly processed = new WeakMap>(); + + public get logRef() { + return { + namespace: this.log.namespace, + text: `queue:${this.queueIdx}`, + }; + } + + constructor( + protected services: TServices, + protected handlers: Handlers, + entrypoint: IBaseEntrypoint + ) { + super(hasLessPriority); + + this.log = entrypoint.log.extend('queue'); + + this.log('Created for entrypoint %s', entrypoint.name); + this.queueIdx = entrypoint.idx; + + this.next('processEntrypoint', entrypoint, {}); + } + + protected override dequeue(): ActionQueueItem | undefined { + let action: ActionQueueItem | undefined; + // eslint-disable-next-line no-cond-assign + while ((action = super.dequeue())) { + if (!action?.abortSignal?.aborted) { + this.log('Dequeued %s: %O', keyOf(action), this.data.map(keyOf)); + + return action; + } + + this.log('%s was aborted', keyOf(action)); + } + + return undefined; + } + + protected override enqueue(newAction: ActionQueueItem) { + const key = keyOf(newAction); + + if (!this.processed.has(newAction.entrypoint)) { + this.processed.set(newAction.entrypoint, new Set()); + } + + const processed = this.processed.get(newAction.entrypoint)!; + if (processed.has(key)) { + this.log('Skip %s because it was already processed', key); + return; + } + + const onAbort = () => { + this.services.eventEmitter.single({ + type: 'queue-action', + queueIdx: this.queueIdx, + action: `${newAction.type}:abort`, + file: newAction.entrypoint.name, + args: newAction.entrypoint.only, + }); + this.delete(newAction); + }; + + newAction.abortSignal?.addEventListener('abort', onAbort); + + super.enqueue(newAction, () => { + newAction.abortSignal?.removeEventListener('abort', onAbort); + }); + + processed.add(key); + this.log('Enqueued %s: %O', key, this.data.map(keyOf)); + } + + public next = ( + actionType: TType, + entrypoint: IBaseEntrypoint, + data: DataOf>, + abortSignal: AbortSignal | null = null + ): ActionByType => { + const action = createAction(actionType, entrypoint, data, abortSignal); + + this.enqueue(action); + + return action; + }; + + protected handle(action: TAction): TRes { + const handler = this.handlers[action.type as TAction['type']] as Handler< + TServices, + TAction, + TRes + >; + + return actionRunner( + this.services, + this.enqueue.bind(this), + handler, + action, + this.queueIdx + ); + } +} diff --git a/packages/babel/src/transform-stages/queue/PriorityQueue.ts b/packages/babel/src/transform-stages/queue/PriorityQueue.ts index 43b2c7c92..62001b74e 100644 --- a/packages/babel/src/transform-stages/queue/PriorityQueue.ts +++ b/packages/babel/src/transform-stages/queue/PriorityQueue.ts @@ -1,25 +1,28 @@ -import type { Debugger } from '@linaria/logger'; +import { nanoid } from 'nanoid'; -import type { IEntrypoint } from './types'; +const keys = new WeakMap(); +const keyFor = (obj: object | number | string): string => { + if (typeof obj === 'object') { + if (!keys.has(obj)) { + keys.set(obj, nanoid(10)); + } -export interface IBaseNode { - entrypoint: IEntrypoint; - refCount?: number; - stack: string[]; - type: string; -} + return keys.get(obj)!; + } -export type Next = (item: TNode) => void; + return obj.toString(); +}; -export abstract class PriorityQueue { - private data: Array = []; +export abstract class PriorityQueue< + TNode extends object | { toString(): string } +> { + protected data: Array = []; - private keys: Map = new Map(); + protected keys: Map = new Map(); + + protected dequeueCallbacks: Map void> = new Map(); protected constructor( - private readonly log: Debugger, - private readonly keyOf: (node: TNode) => string, - private readonly merge: (a: TNode, b: TNode, next: Next) => void, private readonly hasLessPriority: (a: TNode, b: TNode) => boolean ) {} @@ -27,27 +30,40 @@ export abstract class PriorityQueue { return this.data.length; } - private delete(key: string) { + protected delete(node: TNode) { + const key = keyFor(node); const idx = this.keys.get(key); if (idx === undefined) return; if (idx === this.size - 1) { - this.data.pop(); + const deleted = this.data.pop(); + this.onDequeue(deleted); this.keys.delete(key); return; } if (this.size <= 1) { + const deleted = this.data[0]; this.data = []; this.keys.clear(); + this.onDequeue(deleted); return; } + const deleted = this.data[idx]; this.data[idx] = this.data.pop()!; this.keys.delete(key); this.updateKey(idx + 1); this.heapifyDown(1); this.heapifyUp(this.size); + this.onDequeue(deleted); + } + + private onDequeue(node: TNode | undefined) { + if (node === undefined) return; + const callback = this.dequeueCallbacks.get(node); + this.dequeueCallbacks.delete(node); + callback?.(); } private heapifyDown(i = 1): void { @@ -105,7 +121,7 @@ export abstract class PriorityQueue { } private updateKey(i: number) { - this.keys.set(this.keyOf(this.data[i - 1]), i - 1); + this.keys.set(keyFor(this.data[i - 1]), i - 1); } protected dequeue(): TNode | undefined { @@ -115,7 +131,7 @@ export abstract class PriorityQueue { if (this.size === 1) { this.data = []; this.keys.clear(); - this.log('Dequeued %s', this.keyOf(max)); + this.onDequeue(max); return max; } @@ -126,33 +142,22 @@ export abstract class PriorityQueue { this.heapifyDown(1); } - this.keys.delete(this.keyOf(max)); - this.log('Dequeued %s: %o', this.keyOf(max), this.data.map(this.keyOf)); + this.keys.delete(keyFor(max)); + this.onDequeue(max); return max; } - protected enqueue(el: TNode) { - const key = this.keyOf(el); - const idx = this.keys.get(key); - if (idx !== undefined) { - // Merge with existing entry - const node = this.data[idx]; - this.delete(key); - this.merge(node, el, (newNode) => { - this.enqueue(newNode); - }); - - return; + protected enqueue(newNode: TNode, onDequeue?: () => void) { + const key = keyFor(newNode); + if (this.keys.has(key)) { + throw new Error(`Key ${key} already exists`); } - this.increaseKey(this.size + 1, el); - this.log('Enqueued %s: %o', key, this.data.map(this.keyOf)); - if (el.entrypoint.abortSignal) { - el.entrypoint.abortSignal.addEventListener('abort', () => { - this.log('Aborting %s', key); - this.delete(key); - }); + if (onDequeue) { + this.dequeueCallbacks.set(newNode, onDequeue); } + + this.increaseKey(this.size + 1, newNode); } public isEmpty() { diff --git a/packages/babel/src/transform-stages/queue/__tests__/AsyncActionQueue.test.ts b/packages/babel/src/transform-stages/queue/__tests__/AsyncActionQueue.test.ts new file mode 100644 index 000000000..20303c3b5 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/__tests__/AsyncActionQueue.test.ts @@ -0,0 +1,113 @@ +/* eslint-disable no-await-in-loop */ +import { EventEmitter } from '@linaria/utils'; + +import { TransformCacheCollection } from '../../../cache'; +import { AsyncActionQueue } from '../ActionQueue'; +import type { Handlers } from '../GenericActionQueue'; +import { processEntrypoint } from '../actions/processEntrypoint'; +import type { + Services, + IBaseAction, + ITransformAction, + IBaseServices, + Handler, + IExplodeReexportsAction, +} from '../types'; + +import { createEntrypoint } from './entrypoint-helpers'; + +type Res = Promise | void; +type AsyncHandlers = Handlers | void, IBaseServices>; +type GetHandler = Handler; + +describe('AsyncActionQueue', () => { + let services: Pick; + let handlers: AsyncHandlers; + beforeEach(() => { + handlers = { + addToCodeCache: jest.fn(), + transform: jest.fn(), + explodeReexports: jest.fn(), + processEntrypoint: jest.fn(), + processImports: jest.fn(), + getExports: jest.fn(), + resolveImports: jest.fn(), + }; + + services = { + cache: new TransformCacheCollection(), + eventEmitter: EventEmitter.dummy, + }; + }); + + const createQueueFor = ( + name: string, + customHandlers: Partial = {} + ) => { + const entrypoint = createEntrypoint(services, name, ['default']); + return new AsyncActionQueue( + services, + { ...handlers, ...customHandlers }, + entrypoint + ); + }; + + it('should merge actions', () => { + const fooBarQueue = createQueueFor('/foo/bar.js', { + processEntrypoint, + }); + const fooBazQueue = createQueueFor('/foo/baz.js', { + processEntrypoint, + }); + const fooBarEntry = createEntrypoint(services, '/foo/bar.js', ['default']); + fooBazQueue.next('processEntrypoint', fooBarEntry, {}); + + fooBazQueue.runNext(); // pop processEntrypoint for /foo/baz.js + + // both queues should now have the same processEntrypoint action for /foo/bar.js + expect(fooBarQueue.runNext()).toBe(fooBazQueue.runNext()); + }); + + it('should emit new action in both queues', () => { + const fooBarTransform: GetHandler = jest.fn(); + const fooBarExplodeReexports: GetHandler = + jest.fn(); + const fooBarQueue = createQueueFor('/foo/bar.js', { + processEntrypoint, + transform: fooBarTransform, + explodeReexports: fooBarExplodeReexports, + }); + fooBarQueue.runNext(); + + const fooBazTransform: GetHandler = jest.fn(); + const fooBazExplodeReexports: GetHandler = + jest.fn(); + const fooBazQueue = createQueueFor('/foo/baz.js', { + processEntrypoint, + transform: fooBazTransform, + explodeReexports: fooBazExplodeReexports, + }); + createEntrypoint(services, '/foo/bar.js', ['Bar']); + + // At that point, both queues should have the same processEntrypoint action for /foo/bar.js + // If we run this merged action in one queue, it should emit a new `transform` action in both queues + fooBarQueue.runNext(); + + // Drain fooBarQueue + expect(fooBarTransform).not.toHaveBeenCalled(); + fooBarQueue.runNext(); // should be the explodeReexports action + expect(fooBarExplodeReexports).toHaveBeenCalledTimes(1); + fooBarQueue.runNext(); // should be the transform action + expect(fooBarTransform).toHaveBeenCalledTimes(1); + expect(fooBarQueue.isEmpty()).toBe(true); + + fooBazQueue.runNext(); // run processEntrypoint for /foo/baz.js + expect(fooBazTransform).not.toHaveBeenCalled(); + expect(fooBazExplodeReexports).not.toHaveBeenCalled(); + fooBazQueue.runNext(); // run explodeReexports for /foo/baz.js + expect(fooBazExplodeReexports).toHaveBeenCalledTimes(1); + fooBazQueue.runNext(); // run transform for /foo/baz.js + expect(fooBazTransform).toHaveBeenCalledTimes(1); + expect(fooBazQueue.isEmpty()).toBe(true); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/__tests__/GenericQueue.test.ts b/packages/babel/src/transform-stages/queue/__tests__/GenericQueue.test.ts new file mode 100644 index 000000000..3c5cdff28 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/__tests__/GenericQueue.test.ts @@ -0,0 +1,197 @@ +/* eslint-disable no-await-in-loop */ +import { EventEmitter, getFileIdx } from '@linaria/utils'; + +import type { IBaseEntrypoint } from '../../../types'; +import { AsyncActionQueue, SyncActionQueue } from '../ActionQueue'; +import type { Handlers } from '../GenericActionQueue'; +import { rootLog } from '../rootLog'; +import type { + Handler, + IBaseAction, + IBaseServices, + IGetExportsAction, + IProcessEntrypointAction, +} from '../types'; + +const createEntrypoint = (name: string): IBaseEntrypoint => ({ + name, + idx: getFileIdx(name).toString().padStart(5, '0'), + only: ['default'], + log: rootLog, + parent: null, +}); + +type Res = Promise | void; +type UniversalHandlers = Handlers; + +type GetHandler = Handler; + +type Queues = typeof AsyncActionQueue | typeof SyncActionQueue; + +describe.each<[string, Queues]>([ + ['AsyncActionQueue', AsyncActionQueue], + ['SyncActionQueue', SyncActionQueue], +])('%s', (_name, Queue) => { + let services: IBaseServices; + let handlers: UniversalHandlers; + beforeEach(() => { + handlers = { + addToCodeCache: jest.fn(), + transform: jest.fn(), + explodeReexports: jest.fn(), + processEntrypoint: jest.fn(), + processImports: jest.fn(), + getExports: jest.fn(), + resolveImports: jest.fn(), + }; + + services = { + eventEmitter: EventEmitter.dummy, + }; + }); + + const createQueueFor = ( + name: string, + customHandlers: Partial = {} + ) => { + const entrypoint = createEntrypoint(name); + return new Queue(services, { ...handlers, ...customHandlers }, entrypoint); + }; + + describe('base', () => { + it('should be defined', () => { + expect(Queue).toBeDefined(); + }); + + it('should create queue', () => { + const queue = createQueueFor('/foo/bar.js'); + expect(queue).toBeDefined(); + expect(queue.isEmpty()).toBe(false); + Object.values(handlers).forEach((handler) => { + expect(handler).not.toHaveBeenCalled(); + }); + }); + + it('should run processEntrypoint', () => { + const queue = createQueueFor('/foo/bar.js'); + queue.runNext(); + expect(handlers.processEntrypoint).toHaveBeenCalledTimes(1); + expect(queue.isEmpty()).toBe(true); + }); + + it('should process next calls', async () => { + const processEntrypoint: GetHandler = ( + _services, + action, + next + ) => { + next('transform', action.entrypoint, {}); + }; + + const queue = createQueueFor('/foo/bar.js', { processEntrypoint }); + await queue.runNext(); + expect(queue.isEmpty()).toBe(false); + await queue.runNext(); + expect(queue.isEmpty()).toBe(true); + expect(handlers.transform).toHaveBeenCalledTimes(1); + }); + + it('should call actions according to its weight', async () => { + const processEntrypoint: GetHandler = ( + _services, + action, + next + ) => { + next('transform', action.entrypoint, {}); + next('addToCodeCache', action.entrypoint, { + data: { + imports: null, + result: { + code: '', + metadata: undefined, + }, + only: [], + }, + }); + next('explodeReexports', action.entrypoint, {}); + next('processImports', action.entrypoint, { + resolved: [], + }); + next('getExports', action.entrypoint, {}); + next('resolveImports', action.entrypoint, { + imports: null, + }); + }; + + const queue = createQueueFor('/foo/bar.js', { processEntrypoint }); + await queue.runNext(); // processEntrypoint + + const rightOrder: (keyof UniversalHandlers)[] = [ + 'resolveImports', + 'getExports', + 'processImports', + 'explodeReexports', + 'transform', + 'addToCodeCache', + ]; + + for (let i = 0; i < rightOrder.length; i++) { + await queue.runNext(); + expect(handlers[rightOrder[i]]).toHaveBeenCalledTimes(1); + } + }); + }); + + it('should work with events', async () => { + const exports: string[] = ['resolved']; + const onGetExports = jest.fn(); + + const processEntrypoint: GetHandler = ( + _services, + action, + next + ) => { + next('getExports', action.entrypoint, {}).on('resolve', onGetExports); + }; + + const getExports: GetHandler = ( + _services, + _action, + _next, + callbacks + ) => { + callbacks.resolve(exports); + }; + + const queue = createQueueFor('/foo/bar.js', { + processEntrypoint, + getExports, + }); + + while (!queue.isEmpty()) { + await queue.runNext(); + } + + expect(onGetExports).toHaveBeenCalledWith(exports); + }); + + it('should remove aborted actions', async () => { + const abortController = new AbortController(); + const processEntrypoint: GetHandler = ( + _services, + action, + next + ) => { + next('transform', action.entrypoint, {}, abortController.signal); + }; + + const queue = createQueueFor('/foo/bar.js', { processEntrypoint }); + await queue.runNext(); // processEntrypoint + + expect(handlers.transform).not.toHaveBeenCalled(); + + abortController.abort(); + + expect(queue.isEmpty()).toBe(true); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/__tests__/PriorityQueue.test.ts b/packages/babel/src/transform-stages/queue/__tests__/PriorityQueue.test.ts new file mode 100644 index 000000000..7dd7cd040 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/__tests__/PriorityQueue.test.ts @@ -0,0 +1,69 @@ +import { PriorityQueue } from '../PriorityQueue'; + +class NumberQueue extends PriorityQueue { + constructor() { + super((a, b) => a < b); + } + + public delete(item: number) { + super.delete(item); + } + + public dequeue() { + return super.dequeue(); + } + + public enqueue(item: number) { + super.enqueue(item); + } + + public dump() { + const result = []; + while (!this.isEmpty()) { + result.push(this.dequeue()); + } + + return result; + } +} + +describe('PriorityQueue', () => { + it('should be defined', () => { + expect(PriorityQueue).toBeDefined(); + }); + + describe('Simple queue of numbers', () => { + describe('emptiness', () => { + it('should be empty', () => { + const queue = new NumberQueue(); + expect(queue.isEmpty()).toBe(true); + }); + + it('should not be empty', () => { + const queue = new NumberQueue(); + queue.enqueue(1); + expect(queue.isEmpty()).toBe(false); + }); + + it('should be empty after dequeue', () => { + const queue = new NumberQueue(); + queue.enqueue(1); + queue.dequeue(); + expect(queue.isEmpty()).toBe(true); + }); + }); + + it('should dequeue in order', () => { + const queue = new NumberQueue(); + [2, 1, 3].forEach((i) => queue.enqueue(i)); + expect(queue.dump()).toEqual([3, 2, 1]); + }); + + it('should dequeue in order after delete', () => { + const queue = new NumberQueue(); + [2, 1, 4, 3, 5].forEach((i) => queue.enqueue(i)); + queue.delete(3); + expect(queue.dump()).toEqual([5, 4, 2, 1]); + }); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/__tests__/createEntrypoint.test.ts b/packages/babel/src/transform-stages/queue/__tests__/createEntrypoint.test.ts new file mode 100644 index 000000000..13557d720 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/__tests__/createEntrypoint.test.ts @@ -0,0 +1,97 @@ +import { EventEmitter } from '@linaria/utils'; + +import { TransformCacheCollection } from '../../../cache'; +import { onSupersede } from '../createEntrypoint'; +import type { Services } from '../types'; + +import { createEntrypoint, fakeLoadAndParse } from './entrypoint-helpers'; + +describe('createEntrypoint', () => { + let services: Pick; + + beforeEach(() => { + services = { + cache: new TransformCacheCollection(), + eventEmitter: EventEmitter.dummy, + }; + + fakeLoadAndParse.mockClear(); + }); + + it('should create a new entrypoint', () => { + const entrypoint = createEntrypoint(services, '/foo/bar.js', ['default']); + expect(entrypoint).toMatchObject({ + name: '/foo/bar.js', + only: ['default'], + parent: null, + }); + }); + + it('should take from cache', () => { + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', ['default']); + const entrypoint2 = createEntrypoint(services, '/foo/bar.js', ['default']); + expect(entrypoint1).toBe(entrypoint2); + }); + + it('should not take from cache if path differs', () => { + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', ['default']); + const entrypoint2 = createEntrypoint(services, '/foo/baz.js', ['default']); + expect(entrypoint1).not.toBe(entrypoint2); + expect(entrypoint1).toMatchObject({ + name: '/foo/bar.js', + only: ['default'], + }); + expect(entrypoint2).toMatchObject({ + name: '/foo/baz.js', + only: ['default'], + }); + }); + + it('should not take from cache if only differs', () => { + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', ['default']); + const entrypoint2 = createEntrypoint(services, '/foo/bar.js', ['named']); + expect(entrypoint1).not.toBe(entrypoint2); + expect(entrypoint2).toMatchObject({ + name: '/foo/bar.js', + only: ['default', 'named'], + }); + }); + + it('should take from cache if only is subset of cached', () => { + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', [ + 'default', + 'named', + ]); + const entrypoint2 = createEntrypoint(services, '/foo/bar.js', ['default']); + expect(entrypoint1).toBe(entrypoint2); + }); + + it('should take from cache if wildcard is cached', () => { + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', ['*']); + const entrypoint2 = createEntrypoint(services, '/foo/bar.js', ['default']); + expect(entrypoint1).toBe(entrypoint2); + }); + + it('should call callback if entrypoint was superseded', () => { + const callback = jest.fn(); + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', ['default']); + + onSupersede(entrypoint1, callback); + + const entrypoint2 = createEntrypoint(services, '/foo/bar.js', ['named']); + expect(entrypoint1).not.toBe(entrypoint2); + expect(callback).toBeCalledWith(entrypoint2); + }); + + it('should not call supersede callback if it was unsubscribed', () => { + const callback = jest.fn(); + const entrypoint1 = createEntrypoint(services, '/foo/bar.js', ['default']); + + const unsubscribe = onSupersede(entrypoint1, callback); + unsubscribe(); + + const entrypoint2 = createEntrypoint(services, '/foo/bar.js', ['named']); + expect(entrypoint1).not.toBe(entrypoint2); + expect(callback).not.toBeCalled(); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/__tests__/entrypoint-helpers.ts b/packages/babel/src/transform-stages/queue/__tests__/entrypoint-helpers.ts new file mode 100644 index 000000000..dc074ef92 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/__tests__/entrypoint-helpers.ts @@ -0,0 +1,39 @@ +import type { File } from '@babel/types'; + +import type { LoadAndParseFn } from '../createEntrypoint'; +import { genericCreateEntrypoint } from '../createEntrypoint'; +import { rootLog } from '../rootLog'; +import type { IEntrypoint, Services } from '../types'; + +export const fakeLoadAndParse = jest.fn< + ReturnType>, + [] +>(() => ({ + ast: {} as File, + code: '', + evaluator: jest.fn(), + evalConfig: {}, +})); + +export const createEntrypoint = ( + services: Pick, + name: string, + only: string[], + parent: IEntrypoint | null = null +) => { + const entrypoint = genericCreateEntrypoint( + fakeLoadAndParse, + services, + parent ?? { log: rootLog }, + name, + only, + undefined, + null + ); + + if (entrypoint === 'ignored') { + throw new Error('entrypoint was ignored'); + } + + return entrypoint; +}; diff --git a/packages/babel/src/transform-stages/queue/actions/__tests__/action.test.ts b/packages/babel/src/transform-stages/queue/actions/__tests__/action.test.ts new file mode 100644 index 000000000..a9c4cff99 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/__tests__/action.test.ts @@ -0,0 +1,68 @@ +import { EventEmitter } from '@linaria/utils'; + +import { TransformCacheCollection } from '../../../../cache'; +import { + createEntrypoint, + fakeLoadAndParse, +} from '../../__tests__/entrypoint-helpers'; +import type { Services } from '../../types'; +import { createAction } from '../action'; + +describe('createAction', () => { + let services: Pick; + + beforeEach(() => { + services = { + cache: new TransformCacheCollection(), + eventEmitter: EventEmitter.dummy, + }; + + fakeLoadAndParse.mockClear(); + }); + + it('should create an action', () => { + const action = createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + + expect(action).toMatchObject({ + type: 'processEntrypoint', + entrypoint: { + name: '/foo/bar.js', + only: ['default'], + parent: null, + }, + callbacks: {}, + abortSignal: null, + }); + }); + + it('should create an action with callbacks', () => { + const action = createAction( + 'transform', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + const cb1 = jest.fn(); + const cb2 = jest.fn(); + action.on('done', cb1); + action.on('done', cb2); + expect(action.callbacks).toMatchObject({ + done: [cb1, cb2], + }); + }); + + it('should merge two existing actions to a new one', () => { + const entrypoint = createEntrypoint(services, '/foo/bar.js', ['default']); + + const action1 = createAction('processEntrypoint', entrypoint, {}, null); + + const action2 = createAction('processEntrypoint', entrypoint, {}, null); + + expect(action1).not.toBe(action2); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/actions/__tests__/actionRunner.test.ts b/packages/babel/src/transform-stages/queue/actions/__tests__/actionRunner.test.ts new file mode 100644 index 000000000..9cac3ce80 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/__tests__/actionRunner.test.ts @@ -0,0 +1,193 @@ +import { EventEmitter } from '@linaria/utils'; + +import { TransformCacheCollection } from '../../../../cache'; +import { + createEntrypoint, + fakeLoadAndParse, +} from '../../__tests__/entrypoint-helpers'; +import type { + Next, + Services, + IProcessEntrypointAction, + ActionQueueItem, + Handler, +} from '../../types'; +import { createAction } from '../action'; +import { actionRunner } from '../actionRunner'; + +describe('actionRunner', () => { + let services: Pick; + beforeEach(() => { + services = { + cache: new TransformCacheCollection(), + eventEmitter: EventEmitter.dummy, + }; + + fakeLoadAndParse.mockClear(); + }); + + it('should be defined', () => { + expect(actionRunner).toBeDefined(); + }); + + it('should run action', () => { + const action = createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + + const handler = jest.fn(); + + actionRunner(services, () => {}, handler, action, '0001'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should run action with callbacks', () => { + const onDone = jest.fn(); + const action = createAction( + 'transform', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + + action.on('done', onDone); + + actionRunner( + services, + () => {}, + (s, a, n, callbacks) => { + callbacks.done(); + }, + action, + '0001' + ); + + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('should not run action if its copy was already run', () => { + const action1 = createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + const action2 = createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + + const handler = jest.fn(); + + const task1 = actionRunner(services, () => {}, handler, action1, '0001'); + const task2 = actionRunner(services, () => {}, handler, action2, '0002'); + expect(handler).toHaveBeenCalledTimes(1); + expect(task1).toBe(task2); + }); + + it('should call next for both copy', () => { + const enqueue = jest.fn(); + const action1 = createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + const action2 = createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ); + + const handler = (s: unknown, a: IProcessEntrypointAction, n: Next) => { + n('resolveImports', a.entrypoint, { imports: new Map() }, null); + }; + + actionRunner(services, enqueue, handler, action1, '0001'); + actionRunner(services, enqueue, handler, action2, '0002'); + + expect(enqueue).toHaveBeenCalledTimes(2); + // Both actions should be called with the same arguments + expect(enqueue).lastCalledWith(...enqueue.mock.calls[0]); + }); + + it('should call callback for emitted actions', () => { + const queue1: ActionQueueItem[] = [ + createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ), + ]; + const queue2: ActionQueueItem[] = [ + createAction( + 'processEntrypoint', + createEntrypoint(services, '/foo/bar.js', ['default']), + {}, + null + ), + ]; + + const enqueue = (queue: ActionQueueItem[], action: ActionQueueItem) => { + queue.push(action); + return action; + }; + + const runNthFrom = ( + idx: number, + queue: ActionQueueItem[], + handler: ( + s: unknown, + a: ActionQueueItem, + n: Next, + callbacks: Record void> + ) => void = () => {} + ) => + actionRunner( + services, + enqueue.bind(null, queue), + handler as Handler< + Pick, + ActionQueueItem, + Promise | void + >, + queue[idx], + `000${queue}` + ); + + // Both queues should have one action + expect(queue1).toHaveLength(1); + expect(queue2).toHaveLength(1); + + runNthFrom(0, queue1, (s, a, n) => { + n('transform', a.entrypoint, { imports: new Map() }, null).on( + 'done', + () => { + n('resolveImports', a.entrypoint, { imports: new Map() }, null); + } + ); + }); + + // The first action from queue1 has been run and emitted a new action + expect(queue1).toHaveLength(2); + // The second queue shouldn't be affected + expect(queue2).toHaveLength(1); + + // Simulate a traffic jam in queue1 and start running actions from queue2 + runNthFrom(0, queue2); + runNthFrom(1, queue2, (s, a, n, callbacks) => { + callbacks.done(); + }); + + // Both queues should have three actions + expect(queue1).toHaveLength(3); + expect(queue2).toHaveLength(3); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/actions/__tests__/processEntrypoint.test.ts b/packages/babel/src/transform-stages/queue/actions/__tests__/processEntrypoint.test.ts new file mode 100644 index 000000000..83cb525d4 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/__tests__/processEntrypoint.test.ts @@ -0,0 +1,101 @@ +import { EventEmitter } from '@linaria/utils'; + +import { TransformCacheCollection } from '../../../../cache'; +import { + createEntrypoint, + fakeLoadAndParse, +} from '../../__tests__/entrypoint-helpers'; +import type { Next, Services } from '../../types'; +import { createAction } from '../action'; +import { processEntrypoint } from '../processEntrypoint'; + +describe('processEntrypoint', () => { + let services: Pick; + const next = jest.fn, Parameters>((type, ep, data) => + createAction(type, ep, data, null) + ); + + beforeEach(() => { + services = { + cache: new TransformCacheCollection(), + eventEmitter: EventEmitter.dummy, + }; + + fakeLoadAndParse.mockClear(); + next.mockClear(); + }); + + it('should emit explodeReexports and transform actions', async () => { + const fooBarDefault = createEntrypoint(services, '/foo/bar.js', [ + 'default', + ]); + + const action = createAction('processEntrypoint', fooBarDefault, {}, null); + + processEntrypoint(services, action, next as Next); + + expect(next).toHaveBeenCalledTimes(2); + expect(next).toHaveBeenNthCalledWith( + 1, + 'explodeReexports', + fooBarDefault, + {}, + expect.any(AbortSignal) + ); + + expect(next).toHaveBeenNthCalledWith( + 2, + 'transform', + fooBarDefault, + {}, + expect.any(AbortSignal) + ); + }); + + it('should re-emit processEntrypoint if entrypoint was superseded', async () => { + const fooBarDefault = createEntrypoint(services, '/foo/bar.js', [ + 'default', + ]); + + const action = createAction('processEntrypoint', fooBarDefault, {}, null); + + processEntrypoint(services, action, next as Next); + + expect(next).toHaveBeenCalledTimes(2); + + const fooBarNamed = createEntrypoint(services, '/foo/bar.js', ['named']); + + expect(next).toHaveBeenCalledTimes(3); + expect(next).toHaveBeenLastCalledWith( + 'processEntrypoint', + fooBarNamed, + {}, + null + ); + }); + + it('should abort previously emitted actions if entrypoint was superseded', async () => { + const fooBarDefault = createEntrypoint(services, '/foo/bar.js', [ + 'default', + ]); + + const action = createAction('processEntrypoint', fooBarDefault, {}, null); + + processEntrypoint(services, action, next as Next); + + expect(next).toHaveBeenCalledTimes(2); + const emitted = next.mock.calls.map(([, , , abortSignal]) => abortSignal); + expect(emitted.map((signal) => signal?.aborted)).toEqual([false, false]); + + const fooBarNamed = createEntrypoint(services, '/foo/bar.js', ['named']); + expect(emitted.map((signal) => signal?.aborted)).toEqual([true, true]); + + expect(next).toHaveBeenCalledTimes(3); + expect(next).toHaveBeenLastCalledWith( + 'processEntrypoint', + fooBarNamed, + {}, + null + ); + }); +}); diff --git a/packages/babel/src/transform-stages/queue/actions/action.ts b/packages/babel/src/transform-stages/queue/actions/action.ts new file mode 100644 index 000000000..cafc7808a --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/action.ts @@ -0,0 +1,101 @@ +import { nanoid } from 'nanoid'; + +import type { IBaseEntrypoint } from '../../../types'; +import type { + ActionQueueItem, + IBaseAction, + DataOf, + EventsHandlers, + ActionByType, +} from '../types'; + +const randomIds = new WeakMap(); +const randomIdFor = (obj: object) => { + if (!randomIds.has(obj)) { + randomIds.set(obj, nanoid(10)); + } + + return randomIds.get(obj); +}; + +export const keyOf = (node: T): string => { + const name = `${node.type}:${node.entrypoint.name}`; + switch (node.type) { + case 'addToCodeCache': + return `${name}:${randomIdFor(node.data)}`; + + case 'processImports': + return `${name}:${randomIdFor(node.resolved)}`; + + case 'resolveImports': + return node.imports ? `${name}:${randomIdFor(node.imports)}` : name; + + default: + return name; + } +}; + +const actionsForEntrypoints = new WeakMap>(); +export const getRefsCount = (entrypoint: IBaseEntrypoint) => + actionsForEntrypoints.get(entrypoint)?.size ?? 0; + +export const addRef = (entrypoint: IBaseEntrypoint, action: IBaseAction) => { + if (!actionsForEntrypoints.has(entrypoint)) { + actionsForEntrypoints.set(entrypoint, new Set()); + } + + actionsForEntrypoints.get(entrypoint)?.add(action); +}; + +function innerCreateAction( + actionType: TType, + entrypoint: IBaseEntrypoint, + data: DataOf>, + abortSignal: AbortSignal | null +): ActionByType { + type Events = (ActionByType extends IBaseAction< + IBaseEntrypoint, + infer TEvents + > + ? TEvents + : Record) & + Record; + + type Callbacks = EventsHandlers; + const callbacks: Callbacks = {}; + + const on = ( + type: T, + callback: (...args: Events[T]) => void + ) => { + if (!callback) { + return; + } + + if (!callbacks[type]) { + callbacks[type] = []; + } + + callbacks[type]!.push(callback); + }; + + return { + ...data, + abortSignal, + callbacks, + type: actionType, + entrypoint, + on, + } as ActionByType; +} + +export function createAction( + actionType: TType, + entrypoint: IBaseEntrypoint, + data: DataOf>, + abortSignal: AbortSignal | null = null +): ActionByType { + const action = innerCreateAction(actionType, entrypoint, data, abortSignal); + addRef(entrypoint, action); + return action; +} diff --git a/packages/babel/src/transform-stages/queue/actions/actionRunner.ts b/packages/babel/src/transform-stages/queue/actions/actionRunner.ts new file mode 100644 index 000000000..b54a1efe2 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/actionRunner.ts @@ -0,0 +1,152 @@ +import type { IBaseEntrypoint } from '../../../types'; +import type { + IBaseAction, + EventEmitters, + IBaseServices, + Handler, + ActionQueueItem, +} from '../types'; + +import { createAction, keyOf } from './action'; + +class ListOfEmittedActions { + private readonly list: ActionQueueItem[] = []; + + private readonly callbacks: ((action: ActionQueueItem) => void)[] = []; + + public add(action: ActionQueueItem) { + this.list.push(action); + this.callbacks.forEach((cb) => cb(action)); + } + + public onAdd(cb: (action: ActionQueueItem) => void) { + this.list.forEach(cb); + this.callbacks.push(cb); + } +} + +interface IResults { + actions: ListOfEmittedActions; + task: unknown; +} + +const cache = new WeakMap>(); + +function run< + TServices extends IBaseServices, + TAction extends ActionQueueItem, + TRes +>( + services: TServices, + handler: Handler, + action: TAction, + queueIdx: string +): IResults { + type Callbacks = EventEmitters< + TAction extends IBaseAction + ? TEvents + : Record + >; + const allCallbacks = action.callbacks as Record< + keyof Callbacks, + ((...args: unknown[]) => void)[] | undefined + >; + + const callbacks = new Proxy({} as Callbacks, { + get: (target, prop) => { + const callbackName = prop.toString() as keyof Callbacks; + return (...args: unknown[]) => { + if (!action.callbacks) { + return; + } + + services.eventEmitter.single({ + type: 'queue-action', + queueIdx, + action: `${action.type}:${callbackName.toString()}`, + file: action.entrypoint.name, + args, + }); + + allCallbacks[callbackName]?.forEach((cb) => cb(...args)); + }; + }, + }); + + const actions = new ListOfEmittedActions(); + + const task = services.eventEmitter.pair( + { + method: `queue:${action.type}`, + }, + () => + handler( + services, + action, + (type, entrypoint, data, abortSignal) => { + const nextAction = createAction( + type, + entrypoint, + data, + abortSignal === undefined ? action.abortSignal : abortSignal + ); + + actions.add(nextAction); + + return nextAction; + }, + callbacks + ) + ); + + return { actions, task }; +} + +/** + * actionRunner ensures that each action is only run once per entrypoint. + * If action is already running, it will re-emmit actions from the previous run. + */ +export function actionRunner< + TServices extends IBaseServices, + TAction extends ActionQueueItem, + TRes +>( + services: TServices, + enqueue: (action: ActionQueueItem) => void, + handler: Handler, + action: TAction, + queueIdx: string +): TRes { + if (!cache.has(action.entrypoint)) { + cache.set(action.entrypoint, new Map()); + } + + const entrypointCache = cache.get(action.entrypoint)!; + const actionKey = keyOf(action); + const cached = entrypointCache.get(actionKey); + services.eventEmitter.single({ + type: 'queue-action', + queueIdx, + action: `${action.type}:${cached ? 'replay' : 'run'}`, + file: action.entrypoint.name, + args: action.entrypoint.only, + }); + + if (!cached) { + action.entrypoint.log('run action %s', action.type); + const result = run(services, handler, action, queueIdx); + result.actions.onAdd((nextAction) => { + enqueue(nextAction); + }); + + entrypointCache.set(actionKey, result); + return result.task as TRes; + } + + action.entrypoint.log('replay actions %s', action.type); + cached.actions.onAdd((nextAction) => { + enqueue(nextAction); + }); + + return cached.task as TRes; +} diff --git a/packages/babel/src/transform-stages/queue/actions/addToCodeCache.ts b/packages/babel/src/transform-stages/queue/actions/addToCodeCache.ts index 97ee45dbf..6b86a5f20 100644 --- a/packages/babel/src/transform-stages/queue/actions/addToCodeCache.ts +++ b/packages/babel/src/transform-stages/queue/actions/addToCodeCache.ts @@ -1,8 +1,13 @@ -import type { IAddToCodeCacheAction, Services } from '../types'; +import type { IAddToCodeCacheAction, Next, Services } from '../types'; export function addToCodeCache( { cache }: Services, - action: IAddToCodeCacheAction + action: IAddToCodeCacheAction, + next: Next, + callbacks: { + done: () => void; + } ) { cache.add('code', action.entrypoint.name, action.data); + callbacks.done(); } diff --git a/packages/babel/src/transform-stages/queue/actions/explodeReexports.ts b/packages/babel/src/transform-stages/queue/actions/explodeReexports.ts new file mode 100644 index 000000000..4d09ce329 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/explodeReexports.ts @@ -0,0 +1,89 @@ +import type { ExportAllDeclaration, Node, File } from '@babel/types'; + +import type { Core } from '../../../babel'; +import type { IExplodeReexportsAction, Next, Services } from '../types'; + +import { findExportsInImports } from './getExports'; + +const getWildcardReexport = (babel: Core, ast: File) => { + const reexportsFrom: { source: string; node: ExportAllDeclaration }[] = []; + ast.program.body.forEach((node) => { + if ( + babel.types.isExportAllDeclaration(node) && + node.source && + babel.types.isStringLiteral(node.source) + ) { + reexportsFrom.push({ + source: node.source.value, + node, + }); + } + }); + + return reexportsFrom; +}; + +/** + * Replaces wildcard reexports with named reexports. + * Recursively emits getExports for each reexported module, + * and replaces wildcard with resolved named. + */ +export function explodeReexports( + services: Services, + action: IExplodeReexportsAction, + next: Next +) { + const { log, ast } = action.entrypoint; + + const reexportsFrom = getWildcardReexport(services.babel, ast); + if (!reexportsFrom.length) { + return; + } + + log('has wildcard reexport from %o', reexportsFrom); + + const replacements = new Map(); + const onResolved = (res: Record) => { + Object.entries(res).forEach(([source, identifiers]) => { + const reexport = reexportsFrom.find((i) => i.source === source); + if (reexport) { + replacements.set( + reexport.node, + identifiers.length + ? services.babel.types.exportNamedDeclaration( + null, + identifiers.map((i) => + services.babel.types.exportSpecifier( + services.babel.types.identifier(i), + services.babel.types.identifier(i) + ) + ), + services.babel.types.stringLiteral(source) + ) + : null + ); + } + }); + + // Replace wildcard reexport with named reexports + services.babel.traverse(ast, { + ExportAllDeclaration(path) { + const replacement = replacements.get(path.node); + if (replacement) { + path.replaceWith(replacement); + } else { + path.remove(); + } + }, + }); + }; + + // Resolve modules + next('resolveImports', action.entrypoint, { + imports: new Map(reexportsFrom.map((i) => [i.source, []])), + }).on('resolve', (resolvedImports) => { + findExportsInImports(services, action, next, resolvedImports, { + resolve: onResolved, + }); + }); +} diff --git a/packages/babel/src/transform-stages/queue/actions/getExports.ts b/packages/babel/src/transform-stages/queue/actions/getExports.ts new file mode 100644 index 000000000..643d41bd4 --- /dev/null +++ b/packages/babel/src/transform-stages/queue/actions/getExports.ts @@ -0,0 +1,120 @@ +import type { IReexport } from '@linaria/utils'; +import { collectExportsAndImports } from '@linaria/utils'; + +import { createEntrypoint } from '../createEntrypoint'; +import type { + IExplodeReexportsAction, + IGetExportsAction, + IResolvedImport, + Next, + Services, +} from '../types'; + +export function findExportsInImports( + services: Services, + action: IGetExportsAction | IExplodeReexportsAction, + next: Next, + imports: IResolvedImport[], + callbacks: { + resolve: (replacements: Record) => void; + } +) { + let remaining = imports.length; + let results: Record = {}; + + const onResolve = (res: Record) => { + results = { + ...results, + ...res, + }; + + remaining -= 1; + + if (remaining === 0) { + callbacks.resolve(results); + } + }; + + if (imports.length === 0) { + callbacks.resolve({}); + return; + } + + imports.forEach((imp) => { + const { resolved } = imp; + if (!resolved) { + throw new Error(`Could not resolve import ${imp.importedFile}`); + } + + const newEntrypoint = createEntrypoint( + services, + action.entrypoint, + resolved, + [], + undefined, + action.entrypoint.pluginOptions + ); + + if (newEntrypoint === 'ignored') { + onResolve({}); + return; + } + + next('getExports', newEntrypoint, {}).on('resolve', (exports) => { + onResolve({ + [imp.importedFile]: exports, + }); + }); + }); +} + +export function getExports( + services: Services, + action: IGetExportsAction, + next: Next, + callbacks: { resolve: (result: string[]) => void } +) { + const { entrypoint } = action; + + entrypoint.log(`get exports from %s`, entrypoint.name); + + let withWildcardReexport: IReexport[] = []; + const result: string[] = []; + + services.babel.traverse(entrypoint.ast!, { + Program(path) { + const { exports, reexports } = collectExportsAndImports(path); + exports.forEach((e) => { + result.push(e.exported); + }); + + reexports.forEach((e) => { + if (e.exported !== '*') { + result.push(e.exported); + } + }); + + withWildcardReexport = reexports.filter((e) => e.exported === '*'); + }, + }); + + if (withWildcardReexport.length) { + const onResolved = (res: Record) => { + Object.values(res).forEach((identifiers) => { + result.push(...identifiers); + }); + + callbacks.resolve(result); + }; + + next('resolveImports', action.entrypoint, { + imports: new Map(withWildcardReexport.map((i) => [i.source, []])), + }).on('resolve', (resolvedImports) => { + findExportsInImports(services, action, next, resolvedImports, { + resolve: onResolved, + }); + }); + } else { + callbacks.resolve(result); + } +} diff --git a/packages/babel/src/transform-stages/queue/actions/processEntrypoint.ts b/packages/babel/src/transform-stages/queue/actions/processEntrypoint.ts index a5185ef36..8653cf590 100644 --- a/packages/babel/src/transform-stages/queue/actions/processEntrypoint.ts +++ b/packages/babel/src/transform-stages/queue/actions/processEntrypoint.ts @@ -1,22 +1,19 @@ -import type { Next } from '../../helpers/ActionQueue'; -import type { IEntrypoint, IProcessEntrypointAction, Services } from '../types'; +import type { IBaseEntrypoint } from '../../../types'; +import { getSupersededWith, onSupersede } from '../createEntrypoint'; +import type { IProcessEntrypointAction, Next } from '../types'; -const includes = (a: string[], b: string[]) => { - if (a.includes('*')) return true; - if (a.length !== b.length) return false; - return a.every((item, index) => item === b[index]); -}; - -const abortControllers = new WeakMap(); +import { getRefsCount } from './action'; /** * The first stage of processing an entrypoint. - * It checks if the file is already processed and if it is, it checks if the `only` option is the same. - * If it is not, it emits a transformation action for the file with the merged `only` option. + * This stage is responsible for: + * - scheduling the explodeReexports action + * - scheduling the transform action + * - rescheduling itself if the entrypoint is superseded */ -export function processEntrypoint( - { cache }: Services, - action: IProcessEntrypointAction, +export function processEntrypoint( + _services: unknown, + action: IProcessEntrypointAction, next: Next ): void { const { name, only, log } = action.entrypoint; @@ -24,47 +21,45 @@ export function processEntrypoint( 'start processing %s (only: %s, refs: %d)', name, only, - action.refCount ?? 0 + getRefsCount(action.entrypoint) ); - const cached = cache.get('entrypoints', name); - // If we already have a result for this file, we should get a result for merged `only` - const mergedOnly = cached?.only - ? Array.from(new Set([...cached.only, ...only])).sort() - : only; - - if (cached) { - if (includes(cached.only, mergedOnly)) { - log('%s is already processed', name); - return; - } + const abortController = new AbortController(); - log( - '%s is already processed, but with different `only` %o (the cached one %o)', - name, - only, - cached?.only - ); + const onParentAbort = () => { + abortController.abort(); + }; - // If we already have a result for this file, we should invalidate it - cache.invalidate('eval', name); - abortControllers.get(cached)?.abort(); + if (action.abortSignal) { + action.abortSignal.addEventListener('abort', onParentAbort); } - const abortController = new AbortController(); - const entrypoint: IEntrypoint = { - ...action.entrypoint, - only: mergedOnly, - abortSignal: abortController.signal, - }; + const supersededWith = getSupersededWith(action.entrypoint); + if (supersededWith) { + next('processEntrypoint', supersededWith, {}, null); + return; + } - abortControllers.set(entrypoint, abortController); + const unsubscribe = onSupersede(action.entrypoint, (newEntrypoint) => { + log( + 'superseded by %s (only: %s, refs: %d)', + newEntrypoint.name, + newEntrypoint.only, + getRefsCount(newEntrypoint) + ); + abortController.abort(); + next('processEntrypoint', newEntrypoint, {}, null); + }); - cache.add('entrypoints', name, entrypoint); + const onDone = () => { + log('done processing %s', name); + unsubscribe(); + action.abortSignal?.removeEventListener('abort', onParentAbort); + }; - next({ - type: 'transform', - entrypoint, - stack: action.stack, - }); + next('explodeReexports', action.entrypoint, {}, abortController.signal); + next('transform', action.entrypoint, {}, abortController.signal).on( + 'done', + onDone + ); } diff --git a/packages/babel/src/transform-stages/queue/actions/processImports.ts b/packages/babel/src/transform-stages/queue/actions/processImports.ts index f2c718702..d66e9d6bd 100644 --- a/packages/babel/src/transform-stages/queue/actions/processImports.ts +++ b/packages/babel/src/transform-stages/queue/actions/processImports.ts @@ -1,60 +1,30 @@ -/* eslint-disable no-restricted-syntax,no-continue,no-await-in-loop */ -import type { Next } from '../../helpers/ActionQueue'; +/* eslint-disable no-restricted-syntax,no-continue */ import { createEntrypoint } from '../createEntrypoint'; -import type { IProcessImportsAction, Services } from '../types'; +import type { IProcessImportsAction, Next, Services } from '../types'; +/** + * Creates new entrypoints and emits processEntrypoint for each resolved import + */ export function processImports( - { babel, cache, options, eventEmitter }: Services, + services: Services, action: IProcessImportsAction, next: Next ) { - const { resolved: resolvedImports, entrypoint, stack } = action; - - for (const { importedFile, importsOnly, resolved } of resolvedImports) { - if (resolved === null) { - entrypoint.log( - `[resolve] ✅ %s in %s is ignored`, - importedFile, - entrypoint.name - ); - continue; - } - - const resolveCacheKey = `${entrypoint.name} -> ${importedFile}`; - const resolveCached = cache.get('resolve', resolveCacheKey); - const importsOnlySet = new Set(importsOnly); - if (resolveCached) { - const [, cachedOnly] = resolveCached.split('\0'); - cachedOnly?.split(',').forEach((token) => { - importsOnlySet.add(token); - }); - } - - cache.add( - 'resolve', - resolveCacheKey, - `${resolved}\0${[...importsOnlySet].join(',')}` - ); + const { resolved: resolvedImports, entrypoint } = action; + for (const { importsOnly, resolved } of resolvedImports) { const nextEntrypoint = createEntrypoint( - babel, - entrypoint.log, - cache, + services, + entrypoint, resolved, - [...importsOnlySet], + importsOnly, undefined, - entrypoint.pluginOptions, - options, - eventEmitter + entrypoint.pluginOptions ); if (nextEntrypoint === 'ignored') { continue; } - next({ - type: 'processEntrypoint', - entrypoint: nextEntrypoint, - stack: [entrypoint.name, ...stack], - }); + next('processEntrypoint', nextEntrypoint, {}, null); } } diff --git a/packages/babel/src/transform-stages/queue/actions/resolveImports.ts b/packages/babel/src/transform-stages/queue/actions/resolveImports.ts index 80cedc629..c2328e16a 100644 --- a/packages/babel/src/transform-stages/queue/actions/resolveImports.ts +++ b/packages/babel/src/transform-stages/queue/actions/resolveImports.ts @@ -1,8 +1,13 @@ /* eslint-disable no-restricted-syntax,no-continue,no-await-in-loop */ +import { getFileIdx } from '@linaria/utils'; + +import type { IBaseEntrypoint } from '../../../types'; +import { getStack } from '../createEntrypoint'; import type { IResolveImportsAction, Services, IResolvedImport, + Next, } from '../types'; const includes = (a: string[], b: string[]) => { @@ -14,64 +19,130 @@ const includes = (a: string[], b: string[]) => { const mergeImports = (a: string[], b: string[]) => { const result = new Set(a); b.forEach((item) => result.add(item)); - return [...result].sort(); + return [...result].filter((i) => i).sort(); }; +function emitDependency( + emitter: Services['eventEmitter'], + entrypoint: IResolveImportsAction['entrypoint'], + imports: IResolvedImport[] +) { + emitter.single({ + type: 'dependency', + file: entrypoint.name, + only: entrypoint.only, + imports: imports.map(({ resolved, importsOnly }) => ({ + from: resolved, + what: importsOnly, + })), + fileIdx: getFileIdx(entrypoint.name).toString().padStart(5, '0'), + }); +} + +function addToCache( + cache: Services['cache'], + entrypoint: IBaseEntrypoint, + resolvedImports: { + importedFile: string; + importsOnly: string[]; + resolved: string | null; + }[] +) { + const filteredImports = resolvedImports.filter((i): i is IResolvedImport => { + if (i.resolved === null) { + entrypoint.log( + `[resolve] ✅ %s in %s is ignored`, + i.importedFile, + entrypoint.name + ); + return false; + } + + return true; + }); + + return filteredImports.map(({ importedFile, importsOnly, resolved }) => { + const resolveCacheKey = `${entrypoint.name} -> ${importedFile}`; + const resolveCached = cache.get('resolve', resolveCacheKey); + const importsOnlySet = new Set(importsOnly); + if (resolveCached) { + const [, cachedOnly] = resolveCached.split('\0'); + cachedOnly?.split(',').forEach((token) => { + if (token) { + importsOnlySet.add(token); + } + }); + } + + cache.add( + 'resolve', + resolveCacheKey, + `${resolved}\0${[...importsOnlySet].join(',')}` + ); + + return { + importedFile, + importsOnly: [...importsOnlySet], + resolved, + }; + }); +} + +/** + * Synchronously resolves specified imports with a provided resolver. + */ export function syncResolveImports( resolve: (what: string, importer: string, stack: string[]) => string, - { eventEmitter }: Services, - action: IResolveImportsAction + { cache, eventEmitter }: Services, + action: IResolveImportsAction, + next: Next, + callbacks: { resolve: (result: IResolvedImport[]) => void } ) { const { imports, entrypoint } = action; const listOfImports = Array.from(imports?.entries() ?? []); const { log } = entrypoint; - if (listOfImports.length > 0) { - const resolvedImports = listOfImports.map(([importedFile, importsOnly]) => { - let resolved: string | null = null; - try { - resolved = resolve(importedFile, entrypoint.name, action.stack); - log( - '[sync-resolve] ✅ %s -> %s (only: %o)', - importedFile, - resolved, - importsOnly - ); - } catch (err) { - log('[sync-resolve] ❌ cannot resolve %s: %O', importedFile, err); - } + if (listOfImports.length === 0) { + emitDependency(eventEmitter, entrypoint, []); - return { + log('%s has no imports', entrypoint.name); + callbacks.resolve([]); + return; + } + + const resolvedImports = listOfImports.map(([importedFile, importsOnly]) => { + let resolved: string | null = null; + try { + resolved = resolve( + importedFile, + entrypoint.name, + getStack(action.entrypoint) + ); + log( + '[sync-resolve] ✅ %s -> %s (only: %o)', importedFile, - importsOnly, resolved, - }; - }); - - eventEmitter.single({ - type: 'dependency', - file: entrypoint.name, - only: entrypoint.only, - imports: resolvedImports.map(({ resolved, importsOnly }) => ({ - from: resolved, - what: importsOnly, - })), - }); - - action.callback?.(resolvedImports); - } else { - eventEmitter.single({ - type: 'dependency', - file: entrypoint.name, - only: entrypoint.only, - imports: [], - }); + importsOnly + ); + } catch (err) { + log('[sync-resolve] ❌ cannot resolve %s: %O', importedFile, err); + } - log('%s has no imports', entrypoint.name); - action.callback?.([]); - } + return { + importedFile, + importsOnly, + resolved, + }; + }); + + const filteredImports = addToCache(cache, entrypoint, resolvedImports); + emitDependency(eventEmitter, entrypoint, filteredImports); + callbacks.resolve(filteredImports); } +/** + * Asynchronously resolves specified imports with a provided resolver. + */ export async function asyncResolveImports( resolve: ( what: string, @@ -79,133 +150,127 @@ export async function asyncResolveImports( stack: string[] ) => Promise, { cache, eventEmitter }: Services, - action: IResolveImportsAction + action: IResolveImportsAction, + next: Next, + callbacks: { resolve: (result: IResolvedImport[]) => void } ) { const { imports, entrypoint } = action; const listOfImports = Array.from(imports?.entries() ?? []); const { log } = entrypoint; - if (listOfImports.length > 0) { - log('resolving %d imports', listOfImports.length); - - const getResolveTask = async ( - importedFile: string, - importsOnly: string[] - ) => { - let resolved: string | null = null; - try { - resolved = await resolve(importedFile, entrypoint.name, action.stack); - } catch (err) { - log( - '[async-resolve] ❌ cannot resolve %s in %s: %O', - importedFile, - entrypoint.name, - err - ); - } + if (listOfImports.length === 0) { + emitDependency(eventEmitter, entrypoint, []); - if (resolved !== null) { - log( - '[async-resolve] ✅ %s (%o) in %s -> %s', - importedFile, - importsOnly, - entrypoint.name, - resolved - ); - } + log('%s has no imports', entrypoint.name); + callbacks.resolve([]); + return; + } + + log('resolving %d imports', listOfImports.length); + + const getResolveTask = async ( + importedFile: string, + importsOnly: string[] + ) => { + let resolved: string | null = null; + try { + resolved = await resolve( + importedFile, + entrypoint.name, + getStack(action.entrypoint) + ); + } catch (err) { + log( + '[async-resolve] ❌ cannot resolve %s in %s: %O', + importedFile, + entrypoint.name, + err + ); + } - return { + if (resolved !== null) { + log( + '[async-resolve] ✅ %s (%o) in %s -> %s', importedFile, importsOnly, - resolved, - }; - }; + entrypoint.name, + resolved + ); + } - const resolvedImports: IResolvedImport[] = await Promise.all( - listOfImports.map(([importedFile, importsOnly]) => { - const resolveCacheKey = `${entrypoint.name} -> ${importedFile}`; - - const cached = cache.get('resolve', resolveCacheKey); - if (cached) { - const [cachedResolved, cachedOnly] = cached.split('\0'); - return { - importedFile, - importsOnly: mergeImports(importsOnly, cachedOnly.split(',')), - resolved: cachedResolved, - }; - } + return { + importedFile, + importsOnly, + resolved, + }; + }; - const cachedTask = cache.get('resolveTask', resolveCacheKey); - if (cachedTask) { - // If we have cached task, we need to merge importsOnly… - const newTask = cachedTask.then((res) => { - if (includes(res.importsOnly, importsOnly)) { - return res; - } - - const merged = mergeImports(res.importsOnly, importsOnly); - - log( - 'merging imports %o and %o: %o', - importsOnly, - res.importsOnly, - merged - ); - - cache.add( - 'resolve', - resolveCacheKey, - `${res.resolved}\0${merged.join(',')}` - ); - - return { ...res, importsOnly: merged }; - }); - - // … and update the cache - cache.add('resolveTask', resolveCacheKey, newTask); - return newTask; - } + const resolvedImports = await Promise.all( + listOfImports.map(([importedFile, importsOnly]) => { + const resolveCacheKey = `${entrypoint.name} -> ${importedFile}`; - const resolveTask = getResolveTask(importedFile, importsOnly).then( - (res) => { - cache.add( - 'resolve', - resolveCacheKey, - `${res.resolved}\0${importsOnly.join(',')}` - ); + const cached = cache.get('resolve', resolveCacheKey); + if (cached) { + const [cachedResolved, cachedOnly] = cached.split('\0'); + return { + importedFile, + importsOnly: mergeImports(importsOnly, cachedOnly.split(',')), + resolved: cachedResolved, + }; + } + const cachedTask = cache.get('resolveTask', resolveCacheKey); + if (cachedTask) { + // If we have cached task, we need to merge importsOnly… + const newTask = cachedTask.then((res) => { + if (includes(res.importsOnly, importsOnly)) { return res; } - ); - cache.add('resolveTask', resolveCacheKey, resolveTask); + const merged = mergeImports(res.importsOnly, importsOnly); - return resolveTask; - }) - ); + log( + 'merging imports %o and %o: %o', + importsOnly, + res.importsOnly, + merged + ); - log('resolved %d imports', resolvedImports.length); - - eventEmitter.single({ - type: 'dependency', - file: entrypoint.name, - only: entrypoint.only, - imports: resolvedImports.map(({ resolved, importsOnly }) => ({ - from: resolved, - what: importsOnly, - })), - }); - - action.callback?.(resolvedImports); - } else { - eventEmitter.single({ - type: 'dependency', - file: entrypoint.name, - only: entrypoint.only, - imports: [], - }); + cache.add( + 'resolve', + resolveCacheKey, + `${res.resolved}\0${merged.join(',')}` + ); - log('%s has no imports', entrypoint.name); - action.callback?.([]); - } + return { ...res, importsOnly: merged }; + }); + + // … and update the cache + cache.add('resolveTask', resolveCacheKey, newTask); + return newTask; + } + + const resolveTask = getResolveTask(importedFile, importsOnly).then( + (res) => { + cache.add( + 'resolve', + resolveCacheKey, + `${res.resolved}\0${importsOnly.join(',')}` + ); + + return res; + } + ); + + cache.add('resolveTask', resolveCacheKey, resolveTask); + + return resolveTask; + }) + ); + + log('resolved %d imports', resolvedImports.length); + + const filteredImports = addToCache(cache, entrypoint, resolvedImports); + emitDependency(eventEmitter, entrypoint, filteredImports); + callbacks.resolve(filteredImports); } diff --git a/packages/babel/src/transform-stages/queue/actions/transform.ts b/packages/babel/src/transform-stages/queue/actions/transform.ts index 2a309d837..ea308186c 100644 --- a/packages/babel/src/transform-stages/queue/actions/transform.ts +++ b/packages/babel/src/transform-stages/queue/actions/transform.ts @@ -11,8 +11,7 @@ import { buildOptions, getPluginKey } from '@linaria/utils'; import type { Core } from '../../../babel'; import type Module from '../../../module'; import withLinariaMetadata from '../../../utils/withLinariaMetadata'; -import type { Next } from '../../helpers/ActionQueue'; -import type { IEntrypoint, ITransformAction, Services } from '../types'; +import type { IEntrypoint, ITransformAction, Next, Services } from '../types'; const EMPTY_FILE = '=== empty file ==='; @@ -65,24 +64,27 @@ function runPreevalStage( return result; } -export function prepareCode( +type PrepareCodeFn = ( babel: Core, item: IEntrypoint, originalAst: File, eventEmitter: EventEmitter -): [code: string, imports: Module['imports'], metadata?: BabelFileMetadata] { +) => [code: string, imports: Module['imports'], metadata?: BabelFileMetadata]; + +export const prepareCode: PrepareCodeFn = ( + babel, + item, + originalAst, + eventEmitter +) => { const { evaluator, log, evalConfig, pluginOptions, only } = item; - const onPreevalFinished = eventEmitter.pair({ - method: 'queue:transform:preeval', - }); - const preevalStageResult = runPreevalStage( - babel, - item, - originalAst, - eventEmitter + const preevalStageResult = eventEmitter.pair( + { + method: 'queue:transform:preeval', + }, + () => runPreevalStage(babel, item, originalAst, eventEmitter) ); - onPreevalFinished(); if ( only.length === 1 && @@ -102,114 +104,69 @@ export function prepareCode( features: pluginOptions.features, }; - const onEvaluatorFinished = eventEmitter.pair({ - method: 'queue:transform:evaluator', - }); - const [, code, imports] = evaluator( - evalConfig, - preevalStageResult.ast!, - preevalStageResult.code!, - evaluatorConfig, - babel + const [, code, imports] = eventEmitter.pair( + { + method: 'queue:transform:evaluator', + }, + () => + evaluator( + evalConfig, + preevalStageResult.ast!, + preevalStageResult.code!, + evaluatorConfig, + babel + ) ); - onEvaluatorFinished(); log('[evaluator:end]'); return [code, imports, preevalStageResult.metadata]; -} - -// const getWildcardReexport = (babel: Core, ast: File): string[] => { -// const reexportsFrom: string[] = []; -// ast.program.body.forEach((node) => { -// if ( -// babel.types.isExportAllDeclaration(node) && -// node.source && -// babel.types.isStringLiteral(node.source) -// ) { -// reexportsFrom.push(node.source.value); -// } -// }); -// -// return reexportsFrom; -// }; +}; -/** - * Prepares the code for evaluation. This includes removing dead and potentially unsafe code. - * If prepared code has imports, they will be resolved in the next step. - * In the end, the prepared code is added to the cache. - */ -export function transform( - { babel, eventEmitter }: Services, +export function internalTransform( + prepareFn: PrepareCodeFn, + services: Services, action: ITransformAction, - next: Next + next: Next, + callbacks: { done: () => void } ): void { - if (!action) { - return; - } - const { name, only, code, log, ast } = action.entrypoint; log('>> (%o)', only); - // const reexportsFrom = getWildcardReexport(babel, ast); - // if (reexportsFrom.length) { - // log('has wildcard reexport from %o', reexportsFrom); - // - // // Resolve modules - // next({ - // type: 'resolveImports', - // entrypoint: action.entrypoint, - // imports: new Map(reexportsFrom.map((i) => [i, ['*']])), - // stack: action.stack, - // callback: (resolvedImports) => { - // console.log('!!!', resolvedImports); - // }, - // }); - // - // // Replace wildcard reexport with named reexports - // - // // Reprocess the code - // - // return; - // } - - const [preparedCode, imports, metadata] = prepareCode( - babel, + const [preparedCode, imports, metadata] = prepareFn( + services.babel, action.entrypoint, ast, - eventEmitter + services.eventEmitter ); if (code === preparedCode) { log('<< (%o)\n === no changes ===', only); } else { - log('<< (%o)\n%s', only, preparedCode || EMPTY_FILE); + log('<< (%o)', only); + log.extend('source')('%s', preparedCode || EMPTY_FILE); } if (preparedCode === '') { log('%s is skipped', name); + callbacks.done(); return; } - next({ - type: 'resolveImports', - entrypoint: action.entrypoint, + next('resolveImports', action.entrypoint, { imports, - stack: action.stack, - callback: (resolvedImports) => { - next({ - type: 'processImports', - entrypoint: action.entrypoint, - resolved: resolvedImports, - stack: action.stack, - }); - }, + }).on('resolve', (resolvedImports) => { + if (resolvedImports.length === 0) { + return; + } + + next('processImports', action.entrypoint, { + resolved: resolvedImports, + }); }); - next({ - type: 'addToCodeCache', - entrypoint: action.entrypoint, + next('addToCodeCache', action.entrypoint, { data: { imports, result: { @@ -218,6 +175,11 @@ export function transform( }, only, }, - stack: action.stack, - }); + }).on('done', callbacks.done); } + +/** + * Prepares the code for evaluation. This includes removing dead and potentially unsafe code. + * Emits resolveImports, processImports and addToCodeCache events. + */ +export const transform = internalTransform.bind(null, prepareCode); diff --git a/packages/babel/src/transform-stages/queue/createEntrypoint.ts b/packages/babel/src/transform-stages/queue/createEntrypoint.ts index dc2808a2b..8194b49d4 100644 --- a/packages/babel/src/transform-stages/queue/createEntrypoint.ts +++ b/packages/babel/src/transform-stages/queue/createEntrypoint.ts @@ -1,11 +1,11 @@ import { readFileSync } from 'fs'; import { dirname, extname } from 'path'; -import type { PluginItem } from '@babel/core'; +import type { PluginItem, TransformOptions } from '@babel/core'; import type { File } from '@babel/types'; import type { Debugger } from '@linaria/logger'; -import type { Evaluator, EventEmitter, StrictOptions } from '@linaria/utils'; +import type { Evaluator, StrictOptions } from '@linaria/utils'; import { buildOptions, getFileIdx, @@ -13,72 +13,50 @@ import { loadBabelOptions, } from '@linaria/utils'; -import type { Core } from '../../babel'; -import type { TransformCacheCollection } from '../../cache'; -import type { Options } from '../../types'; +import type { IBaseEntrypoint } from '../../types'; import { getMatchedRule, parseFile } from '../helpers/parseFile'; -import { rootLog } from './rootLog'; -import type { IEntrypoint } from './types'; +import type { IEntrypoint, Services, IEntrypointCode } from './types'; const EMPTY_FILE = '=== empty file ==='; -const getLogIdx = (filename: string) => - getFileIdx(filename).toString().padStart(5, '0'); +const getIdx = (fn: string) => getFileIdx(fn).toString().padStart(5, '0'); -export function createEntrypoint( - babel: Core, - parentLog: Debugger, - cache: TransformCacheCollection, - name: string, - only: string[], - maybeCode: string | undefined, - pluginOptions: StrictOptions, - options: Pick, - eventEmitter: EventEmitter -): IEntrypoint | 'ignored' { - const finishEvent = eventEmitter.pair({ method: 'createEntrypoint' }); +const includes = (a: string[], b: string[]) => { + if (a.includes('*')) return true; + if (a.length !== b.length) return false; + return a.every((item, index) => item === b[index]); +}; - const log = parentLog.extend( - getLogIdx(name), - parentLog === rootLog ? ':' : '->' - ); - const extension = extname(name); +const isParent = ( + parent: IEntrypoint | { log: Debugger } +): parent is IEntrypoint => 'name' in parent; - if (!pluginOptions.extensions.includes(extension)) { - log( - '[createEntrypoint] %s is ignored. If you want it to be processed, you should add \'%s\' to the "extensions" option.', - name, - extension - ); +export function getStack(entrypoint: IBaseEntrypoint) { + const stack = [entrypoint.name]; - finishEvent(); - return 'ignored'; + let { parent } = entrypoint; + while (parent) { + stack.push(parent.name); + parent = parent.parent; } - const code = maybeCode ?? readFileSync(name, 'utf-8'); - - const { action, babelOptions } = getMatchedRule( - pluginOptions.rules, - name, - code - ); - - if (action === 'ignore') { - log('[createEntrypoint] %s is ignored by rule', name); - cache.add('ignored', name, true); - finishEvent(); - return 'ignored'; - } + return stack; +} - const evaluator: Evaluator = - typeof action === 'function' - ? action - : require(require.resolve(action, { - paths: [dirname(name)], - })).default; +const isModuleResolver = (plugin: PluginItem) => + getPluginKey(plugin) === 'module-resolver'; - // FIXME: All those configs should be memoized +function buildConfigs( + services: Services, + name: string, + pluginOptions: StrictOptions, + babelOptions: TransformOptions | undefined +): { + evalConfig: TransformOptions; + parseConfig: TransformOptions; +} { + const { babel, options } = services; const commonOptions = { ast: true, @@ -100,8 +78,6 @@ export function createEntrypoint( ...rawConfig, }); - const isModuleResolver = (plugin: PluginItem) => - getPluginKey(plugin) === 'module-resolver'; const parseHasModuleResolver = parseConfig.plugins?.some(isModuleResolver); const rawHasModuleResolver = rawConfig.plugins?.some(isModuleResolver); @@ -120,31 +96,263 @@ export function createEntrypoint( ]; } - cache.invalidateIfChanged(name, code); - const evalConfig = loadBabelOptions(babel, name, { babelrc: false, ...rawConfig, }); - const onParseFinished = eventEmitter.pair({ method: 'parseFile' }); - const ast: File = - cache.get('originalAST', name) ?? parseFile(babel, name, code, parseConfig); - onParseFinished(); + return { + evalConfig, + parseConfig, + }; +} + +function loadAndParse( + services: Services, + name: string, + loadedCode: string | undefined, + log: Debugger, + pluginOptions: StrictOptions +) { + const { babel, cache, eventEmitter } = services; + + const extension = extname(name); + + if (!pluginOptions.extensions.includes(extension)) { + log( + '[createEntrypoint] %s is ignored. If you want it to be processed, you should add \'%s\' to the "extensions" option.', + name, + extension + ); + + return 'ignored'; + } + + const code = loadedCode ?? readFileSync(name, 'utf-8'); + + const { action, babelOptions } = getMatchedRule( + pluginOptions.rules, + name, + code + ); + + if (action === 'ignore') { + log('[createEntrypoint] %s is ignored by rule', name); + cache.add('ignored', name, true); + return 'ignored'; + } + + const evaluator: Evaluator = + typeof action === 'function' + ? action + : require(require.resolve(action, { + paths: [dirname(name)], + })).default; - cache.add('originalAST', name, ast); + const { evalConfig, parseConfig } = buildConfigs( + services, + name, + pluginOptions, + babelOptions + ); - log('[createEntrypoint] %s (%o)\n%s', name, only, code || EMPTY_FILE); + const ast: File = eventEmitter.pair( + { method: 'parseFile' }, + () => + cache.get('originalAST', name) ?? + parseFile(babel, name, code, parseConfig) + ); - finishEvent(); return { ast, code, - evalConfig, evaluator, - log, - name, - only: [...only].sort(), - pluginOptions, + evalConfig, + }; +} + +const supersedeHandlers = new WeakMap< + IBaseEntrypoint, + Array<(newEntrypoint: IEntrypoint) => void> +>(); + +export function onSupersede( + entrypoint: T, + callback: (newEntrypoint: T) => void +) { + if (!supersedeHandlers.has(entrypoint)) { + supersedeHandlers.set(entrypoint, []); + } + + const handlers = supersedeHandlers.get(entrypoint)!; + handlers.push(callback as (newEntrypoint: IBaseEntrypoint) => void); + supersedeHandlers.set(entrypoint, handlers); + + return () => { + const index = handlers.indexOf( + callback as (newEntrypoint: IBaseEntrypoint) => void + ); + if (index >= 0) { + handlers.splice(index, 1); + } }; } + +const supersededWith = new WeakMap(); + +export function supersedeEntrypoint( + services: Pick, + oldEntrypoint: IEntrypoint, + newEntrypoint: IEntrypoint +) { + // If we already have a result for this file, we should invalidate it + services.cache.invalidate('eval', oldEntrypoint.name); + + supersededWith.set(oldEntrypoint, newEntrypoint); + supersedeHandlers + .get(oldEntrypoint) + ?.forEach((handler) => handler(newEntrypoint)); +} + +export function getSupersededWith(entrypoint: IBaseEntrypoint) { + return supersededWith.get(entrypoint); +} + +export type LoadAndParseFn = ( + services: TServices, + name: string, + loadedCode: string | undefined, + log: Debugger, + pluginOptions: TPluginOptions +) => IEntrypointCode | 'ignored'; + +const findParent = (name: string, entrypoint: IBaseEntrypoint) => { + let next: IBaseEntrypoint | null = entrypoint; + while (next) { + if (next.name === name) { + return next; + } + + next = next.parent; + } + + return null; +}; + +export function genericCreateEntrypoint< + TServices extends Pick, + TPluginOptions +>( + loadAndParseFn: LoadAndParseFn, + services: TServices, + parent: IEntrypoint | { log: Debugger }, + name: string, + only: string[], + loadedCode: string | undefined, + pluginOptions: TPluginOptions +): IEntrypoint | 'ignored' { + const { cache, eventEmitter } = services; + + return eventEmitter.pair({ method: 'createEntrypoint' }, () => { + if (loadedCode !== undefined) { + cache.invalidateIfChanged(name, loadedCode); + } + + const idx = getIdx(name); + const log = parent.log.extend(idx, isParent(parent) ? '->' : ':'); + + let onCreate: ( + newEntrypoint: IEntrypoint + ) => void = () => {}; + + const cached = cache.get('entrypoints', name) as + | IEntrypoint + | undefined; + const mergedOnly = cached?.only + ? Array.from(new Set([...cached.only, ...only])) + .filter((i) => i) + .sort() + : only; + + if (cached) { + if (includes(cached.only, mergedOnly)) { + log('%s is cached', name); + return cached; + } + + log( + '%s is cached, but with different `only` %o (the cached one %o)', + name, + only, + cached?.only + ); + + onCreate = (newEntrypoint) => { + supersedeEntrypoint(services, cached, newEntrypoint); + }; + } + + const loadedAndParsed = loadAndParseFn( + services, + name, + loadedCode, + log, + pluginOptions + ); + + if (loadedAndParsed === 'ignored') { + return 'ignored'; + } + + cache.invalidateIfChanged(name, loadedAndParsed.code); + cache.add('originalAST', name, loadedAndParsed.ast); + + log.extend('source')( + '[createEntrypoint] %s (%o)\n%s', + name, + mergedOnly, + loadedAndParsed.code || EMPTY_FILE + ); + + const processedParent = isParent(parent) ? findParent(name, parent) : null; + + const newEntrypoint: IEntrypoint = { + ...loadedAndParsed, + idx, + log: processedParent?.log ?? log, + name, + only: mergedOnly, + parent: isParent(parent) ? processedParent?.parent ?? parent : null, + pluginOptions, + }; + + cache.add('entrypoints', name, newEntrypoint); + onCreate(newEntrypoint); + + if (processedParent) { + log('[createEntrypoint] %s is a loop', name); + return 'ignored'; + } + + return newEntrypoint; + }); +} + +export function createEntrypoint( + services: Services, + parent: IEntrypoint | { log: Debugger }, + name: string, + only: string[], + loadedCode: string | undefined, + pluginOptions: StrictOptions +): IEntrypoint | 'ignored' { + return genericCreateEntrypoint( + loadAndParse, + services, + parent, + name, + only, + loadedCode, + pluginOptions + ); +} diff --git a/packages/babel/src/transform-stages/queue/types.ts b/packages/babel/src/transform-stages/queue/types.ts index 735e8fb0b..2bdff7658 100644 --- a/packages/babel/src/transform-stages/queue/types.ts +++ b/packages/babel/src/transform-stages/queue/types.ts @@ -1,14 +1,15 @@ import type { TransformOptions } from '@babel/core'; import type { File } from '@babel/types'; -import type { Debugger } from '@linaria/logger'; import type { Evaluator, StrictOptions, EventEmitter } from '@linaria/utils'; import type { Core } from '../../babel'; import type { TransformCacheCollection } from '../../cache'; -import type { ITransformFileResult, Options } from '../../types'; - -import type { IBaseNode } from './PriorityQueue'; +import type { + IBaseEntrypoint, + ITransformFileResult, + Options, +} from '../../types'; export type Services = { babel: Core; @@ -17,27 +18,113 @@ export type Services = { eventEmitter: EventEmitter; }; -export interface IEntrypoint { - abortSignal?: AbortSignal; +export interface IBaseNode { + type: ActionQueueItem['type']; +} + +export interface IEntrypointCode { ast: File; code: string; evalConfig: TransformOptions; evaluator: Evaluator; - log: Debugger; - name: string; - only: string[]; - pluginOptions: StrictOptions; } -export interface IProcessEntrypointAction extends IBaseNode { +export interface IEntrypoint + extends IBaseEntrypoint, + IEntrypointCode { + pluginOptions: TPluginOptions; +} + +export type EventEmitters> = { + [K in keyof TEvents]: (...args: TEvents[K]) => void; +}; + +export type EventsHandlers> = { + [K in keyof TEvents]?: Array<(...args: TEvents[K]) => void>; +}; + +export type ActionOn> = < + K extends keyof TEvents +>( + type: K, + callback: (...args: TEvents[K]) => void +) => void; + +export type ActionByType = Extract< + ActionQueueItem, + { + type: TType; + } +>; + +export interface IBaseServices { + eventEmitter: EventEmitter; +} + +export interface IBaseAction< + TEntrypoint extends IBaseEntrypoint = IBaseEntrypoint, + TEvents extends Record = Record +> extends IBaseNode { + abortSignal: AbortSignal | null; + callbacks?: EventsHandlers; + entrypoint: TEntrypoint; + on: ActionOn; +} + +export type DataOf = Omit< + TNode, + keyof IBaseAction | 'entrypoint' +>; + +export type Handler< + TServices extends IBaseServices, + TAction extends IBaseAction, + TRes +> = ( + services: TServices, + action: TAction, + next: Next, + callbacks: EventEmitters< + TAction extends IBaseAction + ? TEvents + : Record + > +) => TRes; + +export type Next = ( + type: TType, + entrypoint: IBaseEntrypoint, + data: DataOf>, + abortSignal?: AbortSignal | null +) => Extract; + +export interface IExplodeReexportsAction extends IBaseAction { + type: 'explodeReexports'; +} + +export interface IProcessEntrypointAction< + TEntrypoint extends IBaseEntrypoint = IBaseEntrypoint +> extends IBaseAction { type: 'processEntrypoint'; } -export interface ITransformAction extends IBaseNode { +export interface ITransformAction + extends IBaseAction< + IEntrypoint, + { + done: []; + } + > { type: 'transform'; } -export interface IAddToCodeCacheAction extends IBaseNode { +export interface IAddToCodeCacheAction + extends IBaseAction< + IBaseEntrypoint, + { + done: []; + } + > { type: 'addToCodeCache'; data: { imports: Map | null; @@ -49,23 +136,40 @@ export interface IAddToCodeCacheAction extends IBaseNode { export interface IResolvedImport { importedFile: string; importsOnly: string[]; - resolved: string | null; + resolved: string; } -export interface IResolveImportsAction extends IBaseNode { - callback: (resolved: IResolvedImport[]) => void; - imports: Map | null; +export interface IResolveImportsAction + extends IBaseAction< + IBaseEntrypoint, + { + resolve: [result: IResolvedImport[]]; + } + > { type: 'resolveImports'; + imports: Map | null; } -export interface IProcessImportsAction extends IBaseNode { - resolved: IResolvedImport[]; +export interface IProcessImportsAction extends IBaseAction { type: 'processImports'; + resolved: IResolvedImport[]; +} + +export interface IGetExportsAction + extends IBaseAction< + IEntrypoint, + { + resolve: [exports: string[]]; + } + > { + type: 'getExports'; } export type ActionQueueItem = | IAddToCodeCacheAction + | IExplodeReexportsAction | IProcessEntrypointAction | IProcessImportsAction | IResolveImportsAction + | IGetExportsAction | ITransformAction; diff --git a/packages/babel/src/transform.ts b/packages/babel/src/transform.ts index 1493d9c64..3ebddb5e6 100644 --- a/packages/babel/src/transform.ts +++ b/packages/babel/src/transform.ts @@ -53,17 +53,11 @@ function syncStages( // *** 2nd stage *** - const finishStage2Event = eventEmitter.pair({ stage: 'stage-2', filename }); - - const evalStageResult = evalStage( - cache, - prepareStageResult.code, - pluginOptions, - filename + const evalStageResult = eventEmitter.pair( + { stage: 'stage-2', filename }, + () => evalStage(cache, prepareStageResult.code, pluginOptions, filename) ); - finishStage2Event(); - if (evalStageResult === null) { return { code: originalCode, @@ -75,20 +69,20 @@ function syncStages( // *** 3rd stage *** - const finishStage3Event = eventEmitter.pair({ stage: 'stage-3', filename }); - - const collectStageResult = prepareForRuntime( - babel, - ast, - originalCode, - valueCache, - pluginOptions, - options, - babelConfig + const collectStageResult = eventEmitter.pair( + { stage: 'stage-3', filename }, + () => + prepareForRuntime( + babel, + ast, + originalCode, + valueCache, + pluginOptions, + options, + babelConfig + ) ); - finishStage3Event(); - if (!withLinariaMetadata(collectStageResult.metadata)) { return { code: collectStageResult.code!, @@ -96,25 +90,22 @@ function syncStages( }; } - // *** 4th stage + const linariaMetadata = collectStageResult.metadata.linaria; - const finishStage4Event = eventEmitter.pair({ stage: 'stage-4', filename }); + // *** 4th stage - const extractStageResult = extractStage( - collectStageResult.metadata.linaria.processors, - originalCode, - options + const extractStageResult = eventEmitter.pair( + { stage: 'stage-4', filename }, + () => extractStage(linariaMetadata.processors, originalCode, options) ); - finishStage4Event(); - return { ...extractStageResult, code: collectStageResult.code ?? '', dependencies, replacements: [ ...extractStageResult.replacements, - ...collectStageResult.metadata.linaria.replacements, + ...linariaMetadata.replacements, ], sourceMap: collectStageResult.map, }; @@ -129,35 +120,39 @@ export function transformSync( eventEmitter = EventEmitter.dummy ): Result { const { filename } = options; - // *** 1st stage *** + const results = eventEmitter.pair({ stage: 'stage-1', filename }, () => { + // *** 1st stage *** - const finishEvent = eventEmitter.pair({ stage: 'stage-1', filename }); - - const entrypoint = { - name: options.filename, - code: originalCode, - only: ['__linariaPreval'], - }; + const entrypoint = { + name: options.filename, + code: originalCode, + only: ['__linariaPreval'], + }; - const pluginOptions = loadLinariaOptions(options.pluginOptions); - const prepareStageResults = prepareForEvalSync( - babel, - cache, - syncResolve, - entrypoint, - pluginOptions, - options - ); + const pluginOptions = loadLinariaOptions(options.pluginOptions); + const prepareStageResults = prepareForEvalSync( + babel, + cache, + syncResolve, + entrypoint, + pluginOptions, + options, + eventEmitter + ); - finishEvent(); + return { + prepareStageResults, + pluginOptions, + }; + }); // *** The rest of the stages are synchronous *** return syncStages( originalCode, - pluginOptions, + results.pluginOptions, options, - prepareStageResults, + results.prepareStageResults, babelConfig, cache, eventEmitter @@ -177,37 +172,42 @@ export default async function transform( eventEmitter = EventEmitter.dummy ): Promise { const { filename } = options; - - // *** 1st stage *** - - const finishEvent = eventEmitter.pair({ stage: 'stage-1', filename }); - - const entrypoint = { - name: filename, - code: originalCode, - only: ['__linariaPreval'], - }; - - const pluginOptions = loadLinariaOptions(options.pluginOptions); - const prepareStageResults = await prepareForEval( - babel, - cache, - asyncResolve, - entrypoint, - pluginOptions, - options, - eventEmitter + const results = await eventEmitter.pair( + { stage: 'stage-1', filename }, + async () => { + // *** 1st stage *** + + const entrypoint = { + name: options.filename, + code: originalCode, + only: ['__linariaPreval'], + }; + + const pluginOptions = loadLinariaOptions(options.pluginOptions); + const prepareStageResults = await prepareForEval( + babel, + cache, + asyncResolve, + entrypoint, + pluginOptions, + options, + eventEmitter + ); + + return { + prepareStageResults, + pluginOptions, + }; + } ); - finishEvent(); - // *** The rest of the stages are synchronous *** return syncStages( originalCode, - pluginOptions, + results.pluginOptions, options, - prepareStageResults, + results.prepareStageResults, babelConfig, cache, eventEmitter diff --git a/packages/babel/src/types.ts b/packages/babel/src/types.ts index 8340d7b8f..fa9ec27a3 100644 --- a/packages/babel/src/types.ts +++ b/packages/babel/src/types.ts @@ -3,10 +3,14 @@ import type { NodePath } from '@babel/traverse'; import type { File } from '@babel/types'; import type { RawSourceMap } from 'source-map'; +import type { CustomDebug, Debugger } from '@linaria/logger'; import type { BaseProcessor } from '@linaria/tags'; -import type { LinariaMetadata, Replacement, Rules } from '@linaria/utils'; - -import type { PluginOptions } from './transform-stages/helpers/loadLinariaOptions'; +import type { + LinariaMetadata, + Replacement, + Rules, + StrictOptions, +} from '@linaria/utils'; export type { Value, ValueCache } from '@linaria/tags'; @@ -22,6 +26,27 @@ export type { export { ValueType } from '@linaria/utils'; +export type PluginOptions = StrictOptions & { + configFile?: string | false; + stage?: Stage; +}; + +export interface IModule { + debug: CustomDebug; + readonly exports: unknown; + readonly idx: number; + readonly isEvaluated: boolean; + readonly only: string; +} + +export interface IBaseEntrypoint { + idx: string; + log: Debugger; + name: string; + only: string[]; + parent: IBaseEntrypoint | null; +} + export type Dependencies = string[]; export interface IPluginState extends PluginPass { diff --git a/packages/babel/tsconfig.spec.json b/packages/babel/tsconfig.spec.json new file mode 100644 index 000000000..3a33cf882 --- /dev/null +++ b/packages/babel/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/packages/logger/package.json b/packages/logger/package.json index 7a9ebb718..2802bd623 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -30,11 +30,11 @@ "watch": "pnpm build:lib --watch & pnpm build:esm --watch & pnpm build:declarations --watch" }, "dependencies": { - "debug": "^4.1.1", + "debug": "^4.3.4", "picocolors": "^1.0.0" }, "devDependencies": { - "@types/debug": "^4.1.5", + "@types/debug": "^4.1.8", "@types/node": "^17.0.39" }, "engines": { diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 4b97bd90d..2c9614fcd 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -2,11 +2,6 @@ import genericDebug from 'debug'; import type { Debugger } from 'debug'; import pc from 'picocolors'; -type LogLevel = 'error' | 'warn' | 'info' | 'debug'; - -const levels = ['error', 'warn', 'info', 'debug']; -const currentLevel = levels.indexOf(process.env.LINARIA_LOG || 'warn'); - export type { Debugger }; export const linariaLogger = genericDebug('linaria'); @@ -28,10 +23,13 @@ function gerOrCreate(namespace: string | null | undefined): Debugger { return loggers.get(namespace)!; } -genericDebug.formatters.r = (ref: { namespace: string; text?: string }) => { - const color = parseInt(gerOrCreate(ref.namespace).color, 10); +genericDebug.formatters.r = ( + ref: string | { namespace: string; text?: string } +) => { + const namespace = typeof ref === 'string' ? ref : ref.namespace; + const text = typeof ref === 'string' ? namespace : ref.text ?? namespace; + const color = parseInt(gerOrCreate(namespace).color, 10); const colorCode = `\u001B[3${color < 8 ? color : `8;5;${color}`}`; - const text = ref.text ?? ref.namespace; return `${colorCode};1m${text}\u001B[0m`; }; @@ -47,16 +45,15 @@ const format = (text: T) => { return text; }; -function log( - level: LogLevel, +export function enableDebug(namespace = 'linaria:*') { + genericDebug.enable(namespace); +} + +export function debug( namespaces: string, template: unknown | (() => void), ...restArgs: unknown[] ) { - if (currentLevel < levels.indexOf(level)) { - return; - } - const logger = gerOrCreate(namespaces); if (!logger.enabled) return; @@ -71,11 +68,6 @@ function log( logger(format(template), ...restArgs); } -export const debug = log.bind(null, 'debug'); -export const info = log.bind(null, 'info'); -export const warn = log.bind(null, 'warn'); -export const error = log.bind(null, 'error'); - export const notify = (message: string) => { // eslint-disable-next-line no-console console.log( diff --git a/packages/rollup/src/index.ts b/packages/rollup/src/index.ts index 399228383..24f2ee688 100644 --- a/packages/rollup/src/index.ts +++ b/packages/rollup/src/index.ts @@ -154,7 +154,8 @@ export default function linaria({ vite = { ...vite, buildStart() { - this.warn( + // eslint-disable-next-line no-console + console.warn( 'You are trying to use @linaria/rollup with Vite. The support for Vite in @linaria/rollup is deprecated and will be removed in the next major release. Please use @linaria/vite instead.' ); }, diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 43750f2ea..3ec73878a 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -30,6 +30,7 @@ "@linaria/shaker": "workspace:^", "@linaria/tags": "workspace:^", "@swc/core": "^1.3.20", + "debug": "^4.3.4", "dedent": "^0.7.0", "esbuild": "^0.15.16", "strip-ansi": "^5.2.0", @@ -50,6 +51,7 @@ "@types/babel__core": "^7.1.19", "@types/babel__generator": "^7.6.4", "@types/babel__traverse": "^7.17.1", + "@types/debug": "^4.1.8", "@types/dedent": "^0.7.0", "@types/jest": "^28.1.0", "@types/node": "^17.0.39", diff --git a/packages/testkit/src/__fixtures__/bar.js b/packages/testkit/src/__fixtures__/bar.js index 80afd184e..ce4710d8d 100644 --- a/packages/testkit/src/__fixtures__/bar.js +++ b/packages/testkit/src/__fixtures__/bar.js @@ -1,2 +1,4 @@ +export * from './re-exports/constants'; + export const bar1 = 'bar1'; export const bar2 = 'bar2'; diff --git a/packages/testkit/src/__fixtures__/circular-imports/bar.js b/packages/testkit/src/__fixtures__/circular-imports/bar.js new file mode 100644 index 000000000..9f1738685 --- /dev/null +++ b/packages/testkit/src/__fixtures__/circular-imports/bar.js @@ -0,0 +1 @@ +export const bar = 'bar'; diff --git a/packages/testkit/src/__fixtures__/circular-imports/constants.js b/packages/testkit/src/__fixtures__/circular-imports/constants.js new file mode 100644 index 000000000..20145f59d --- /dev/null +++ b/packages/testkit/src/__fixtures__/circular-imports/constants.js @@ -0,0 +1,4 @@ +import { bar } from './index'; + +export const foo = 'foo'; +export const constBar = bar; diff --git a/packages/testkit/src/__fixtures__/circular-imports/foo.js b/packages/testkit/src/__fixtures__/circular-imports/foo.js new file mode 100644 index 000000000..2b7f03ccc --- /dev/null +++ b/packages/testkit/src/__fixtures__/circular-imports/foo.js @@ -0,0 +1,3 @@ +import * as fooStyles from './constants'; + +export { fooStyles }; diff --git a/packages/testkit/src/__fixtures__/circular-imports/index.js b/packages/testkit/src/__fixtures__/circular-imports/index.js new file mode 100644 index 000000000..4d63d4b59 --- /dev/null +++ b/packages/testkit/src/__fixtures__/circular-imports/index.js @@ -0,0 +1,2 @@ +export * from './bar'; +export * from './foo'; diff --git a/packages/testkit/src/__fixtures__/re-exports/empty.js b/packages/testkit/src/__fixtures__/re-exports/empty.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/testkit/src/__fixtures__/re-exports/empty.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/testkit/src/__fixtures__/re-exports/foo.js b/packages/testkit/src/__fixtures__/re-exports/foo.js index 2b7f03ccc..abe504c29 100644 --- a/packages/testkit/src/__fixtures__/re-exports/foo.js +++ b/packages/testkit/src/__fixtures__/re-exports/foo.js @@ -1,3 +1,4 @@ import * as fooStyles from './constants'; +export * from '../bar'; export { fooStyles }; diff --git a/packages/testkit/src/__fixtures__/re-exports/index.js b/packages/testkit/src/__fixtures__/re-exports/index.js index d6c3be183..8b271f2bd 100644 --- a/packages/testkit/src/__fixtures__/re-exports/index.js +++ b/packages/testkit/src/__fixtures__/re-exports/index.js @@ -1 +1,2 @@ +export * from './empty'; export * from './foo'; diff --git a/packages/testkit/src/__snapshots__/babel.test.ts.snap b/packages/testkit/src/__snapshots__/babel.test.ts.snap index 654462dba..41b2d84bd 100644 --- a/packages/testkit/src/__snapshots__/babel.test.ts.snap +++ b/packages/testkit/src/__snapshots__/babel.test.ts.snap @@ -411,6 +411,48 @@ Dependencies: NA `; +exports[`strategy shaker concurrent two parallel chains of reexports 1`] = ` +"import { styled } from '@linaria/react'; +export const H1 = /*#__PURE__*/styled('h1')({ + name: \\"H1\\", + class: \\"H1_hpsacp3\\", + propsAsIs: false +});" +`; + +exports[`strategy shaker concurrent two parallel chains of reexports 2`] = ` + +CSS: + +.H1_hpsacp3 { + color: foo; +} + +Dependencies: ./__fixtures__/re-exports + +`; + +exports[`strategy shaker concurrent two parallel chains of reexports 3`] = ` +"import { styled } from '@linaria/react'; +export const H1 = /*#__PURE__*/styled('h1')({ + name: \\"H1\\", + class: \\"H1_htinbxh\\", + propsAsIs: false +});" +`; + +exports[`strategy shaker concurrent two parallel chains of reexports 4`] = ` + +CSS: + +.H1_htinbxh { + color: bar2; +} + +Dependencies: ./__fixtures__/re-exports + +`; + exports[`strategy shaker derives display name from filename 1`] = ` "import { styled } from '@linaria/react'; export default /*#__PURE__*/styled('h1')({ @@ -2308,6 +2350,27 @@ Dependencies: NA `; +exports[`strategy shaker should process circular imports 1`] = ` +"import { styled } from '@linaria/react'; +export const H1 = /*#__PURE__*/styled('h1')({ + name: \\"H1\\", + class: \\"H1_h13jq05\\", + propsAsIs: false +});" +`; + +exports[`strategy shaker should process circular imports 2`] = ` + +CSS: + +.H1_h13jq05 { + color: bar; +} + +Dependencies: ./__fixtures__/circular-imports + +`; + exports[`strategy shaker should process griffel makeStyles 1`] = ` "import { __styles as _styles } from \\"@griffel/react\\"; export const useStyles = /*#__PURE__*/_styles({ diff --git a/packages/testkit/src/babel.test.ts b/packages/testkit/src/babel.test.ts index 10e797aaa..5527d0d3d 100644 --- a/packages/testkit/src/babel.test.ts +++ b/packages/testkit/src/babel.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax */ import { readFileSync } from 'fs'; import { dirname, join, resolve } from 'path'; @@ -84,7 +85,8 @@ async function transform( originalCode: string, opts: Options, cache?: TransformCacheCollection, - eventEmitter?: EventEmitter + eventEmitter?: EventEmitter, + filename = 'source' ) { const [ evaluator, @@ -93,7 +95,7 @@ async function transform( babelPartialConfig = {}, ] = opts; - const filename = join(dirName, `source.${extension}`); + const fullFilename = join(dirName, `${filename}.${extension}`); const presets = getPresets(extension); const linariaConfig = getLinariaConfig( @@ -106,7 +108,7 @@ async function transform( const result = await linariaTransform( originalCode, { - filename: babelPartialConfig.filename ?? filename, + filename: babelPartialConfig.filename ?? fullFilename, pluginOptions: linariaConfig, }, asyncResolve, @@ -2569,7 +2571,7 @@ describe('strategy shaker', () => { expect(metadata).toMatchSnapshot(); }); - xit('should ignore unused wildcard reexports', async () => { + it('should ignore unused wildcard reexports', async () => { const onEvent = jest.fn>(); const emitter = new EventEmitter(onEvent); const { code, metadata } = await transform( @@ -2591,7 +2593,7 @@ describe('strategy shaker', () => { const unusedFile = resolve(__dirname, './__fixtures__/bar.js'); expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ file: unusedFile }), + expect.objectContaining({ file: unusedFile, action: 'processImports' }), 'single' ); }); @@ -2732,7 +2734,29 @@ describe('strategy shaker', () => { expect(metadata).toMatchSnapshot(); }); + it('should process circular imports', async () => { + const { code, metadata } = await transform( + dedent` + import { styled } from '@linaria/react'; + import { fooStyles } from "./__fixtures__/circular-imports"; + + const value = fooStyles.constBar; + + export const H1 = styled.h1\` + color: ${'${value}'}; + \` + `, + [evaluator] + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + it('evaluates chain of reexports', async () => { + const onEvent = jest.fn>(); + const emitter = new EventEmitter(onEvent); + const { code, metadata } = await transform( dedent` import { styled } from '@linaria/react'; @@ -2744,11 +2768,29 @@ describe('strategy shaker', () => { color: ${'${value}'}; \` `, - [evaluator] + [evaluator], + undefined, + emitter ); expect(code).toMatchSnapshot(); expect(metadata).toMatchSnapshot(); + + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + file: resolve(__dirname, './__fixtures__/bar.js'), + action: 'processImports', + }), + 'single' + ); + + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + file: resolve(__dirname, './__fixtures__/re-exports/empty.js'), + action: 'processImports', + }), + 'single' + ); }); it('respects module-resolver plugin', async () => { @@ -3066,4 +3108,53 @@ describe('strategy shaker', () => { expect(metadata).toMatchSnapshot(); }); }); + + describe('concurrent', () => { + it('two parallel chains of reexports', async () => { + const cache = new TransformCacheCollection(); + + const onEvent = jest.fn>(); + const emitter = new EventEmitter(onEvent); + + const files = { + 'source-1': dedent` + import { styled } from '@linaria/react'; + import { fooStyles } from "./__fixtures__/re-exports"; + + const value = fooStyles.foo; + + export const H1 = styled.h1\` + color: ${'${value}'}; + \` + `, + 'source-2': dedent` + import { styled } from '@linaria/react'; + import { bar2 } from "./__fixtures__/re-exports"; + + export const H1 = styled.h1\` + color: ${'${bar2}'}; + \` + `, + }; + + const results = await Promise.all( + Object.entries(files).map(([filename, content]) => + transform(content, [evaluator], cache, emitter, filename) + ) + ); + + for (const { code, metadata } of results) { + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + } + + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + file: resolve(__dirname, './__fixtures__/re-exports/empty.js'), + action: 'processImports', + }), + 'single' + ); + }); + }); }); diff --git a/packages/testkit/src/prepareCode.test.ts b/packages/testkit/src/prepareCode.test.ts index 4322e9558..edc560b3f 100644 --- a/packages/testkit/src/prepareCode.test.ts +++ b/packages/testkit/src/prepareCode.test.ts @@ -59,18 +59,19 @@ describe('prepareCode', () => { .map((s) => s.trim()); const sourceCode = restLines.join('\n'); - const entrypoint = createEntrypoint( + const services = { babel, - linariaLogger, - new TransformCacheCollection(), + cache: new TransformCacheCollection(), + options: { root }, + eventEmitter: EventEmitter.dummy, + }; + const entrypoint = createEntrypoint( + services, + { log: linariaLogger }, inputFilePath, only, sourceCode, - pluginOptions, - { - root, - }, - EventEmitter.dummy + pluginOptions ); if (entrypoint === 'ignored') { diff --git a/packages/utils/src/EventEmitter.ts b/packages/utils/src/EventEmitter.ts index 8a1687e36..d168b6cf9 100644 --- a/packages/utils/src/EventEmitter.ts +++ b/packages/utils/src/EventEmitter.ts @@ -9,26 +9,37 @@ export class EventEmitter { constructor(protected onEvent: OnEvent) {} - public autoPair(labels: Record, fn: () => TRes) { + public pair(labels: Record): () => void; + public pair(labels: Record, fn: () => TRes): TRes; + public pair(labels: Record, fn?: () => TRes) { this.onEvent(labels, 'start'); - const result = fn(); - if (result instanceof Promise) { - result.then(() => this.onEvent(labels, 'finish')); - } else { - this.onEvent(labels, 'finish'); - } - return result; - } + if (fn) { + const result = fn(); + if (result instanceof Promise) { + result.then( + () => this.onEvent(labels, 'finish'), + () => this.onEvent(labels, 'finish') + ); + } else { + this.onEvent(labels, 'finish'); + } + + return result; + } - public pair(labels: Record) { - this.onEvent(labels, 'start'); return () => { this.onEvent(labels, 'finish'); }; } public single(labels: Record) { - this.onEvent(labels, 'single'); + this.onEvent( + { + ...labels, + datetime: new Date(), + }, + 'single' + ); } } diff --git a/packages/utils/src/collectExportsAndImports.ts b/packages/utils/src/collectExportsAndImports.ts index ae7e18275..b7b172513 100644 --- a/packages/utils/src/collectExportsAndImports.ts +++ b/packages/utils/src/collectExportsAndImports.ts @@ -27,7 +27,7 @@ import type { VariableDeclarator, } from '@babel/types'; -import { warn } from '@linaria/logger'; +import { debug } from '@linaria/logger'; import { getScope } from './getScope'; import isExports from './isExports'; @@ -224,7 +224,7 @@ function importFromVariableDeclarator( if (!isSync) { // Something went wrong // Is it something like `const { … } = import(…)`? - warn('evaluator:collectExportsAndImports', '`import` should be awaited'); + debug('evaluator:collectExportsAndImports', '`import` should be awaited'); return []; } @@ -233,7 +233,7 @@ function importFromVariableDeclarator( } // What else it can be? - warn( + debug( 'evaluator:collectExportsAndImports:importFromVariableDeclarator', 'Unknown type of id', id.node.type @@ -270,7 +270,7 @@ function exportFromVariableDeclarator( } // What else it can be? - warn( + debug( 'evaluator:collectExportsAndImports:exportFromVariableDeclarator', 'Unknown type of id', id.node.type @@ -398,7 +398,7 @@ function collectFromRequire(path: NodePath, state: IState): void { if (!imported) { // It's not a transpiled import. // TODO: Can we guess that it's a namespace import? - warn( + debug( 'evaluator:collectExportsAndImports', 'Unknown wrapper of require', container.node.callee @@ -426,7 +426,7 @@ function collectFromRequire(path: NodePath, state: IState): void { if (!variableDeclarator.isVariableDeclarator()) { // TODO: Where else it can be? - warn( + debug( 'evaluator:collectExportsAndImports', 'Unexpected require inside', variableDeclarator.node.type @@ -436,7 +436,7 @@ function collectFromRequire(path: NodePath, state: IState): void { const id = variableDeclarator.get('id'); if (!id.isIdentifier()) { - warn( + debug( 'evaluator:collectExportsAndImports', 'Id should be Identifier', variableDeclarator.node.type @@ -466,7 +466,7 @@ function collectFromRequire(path: NodePath, state: IState): void { // It is `require('@linaria/shaker').dep` const property = container.get('property'); if (!property.isIdentifier() && !property.isStringLiteral()) { - warn( + debug( 'evaluator:collectExportsAndImports', 'Property should be Identifier or StringLiteral', property.node.type @@ -488,7 +488,7 @@ function collectFromRequire(path: NodePath, state: IState): void { type: 'cjs', }); } else { - warn( + debug( 'evaluator:collectExportsAndImports', 'Id should be Identifier', variableDeclarator.node.type @@ -799,7 +799,7 @@ function unfoldNamespaceImport( break; } - warn( + debug( 'evaluator:collectExportsAndImports:unfoldNamespaceImports', 'Unknown import type', importType @@ -820,7 +820,7 @@ function unfoldNamespaceImport( // Otherwise, we can't predict usage and import it as is // TODO: handle more cases - warn( + debug( 'evaluator:collectExportsAndImports:unfoldNamespaceImports', 'Unknown reference', referencePath.node.type @@ -897,7 +897,7 @@ function collectFromExportSpecifier( } // TODO: handle other cases - warn( + debug( 'evaluator:collectExportsAndImports:collectFromExportSpecifier', 'Unprocessed ExportSpecifier', path.node.type diff --git a/packages/utils/src/debug/perfMetter.ts b/packages/utils/src/debug/perfMetter.ts index d82e891fd..44323e154 100644 --- a/packages/utils/src/debug/perfMetter.ts +++ b/packages/utils/src/debug/perfMetter.ts @@ -14,6 +14,7 @@ interface IProcessedDependency { exports: string[]; imports: { from: string; what: string[] }[]; passes: number; + fileIdx: string; } export interface IProcessedEvent { @@ -21,15 +22,25 @@ export interface IProcessedEvent { file: string; only: string[]; imports: { from: string; what: string[] }[]; + fileIdx: string; } export interface IQueueActionEvent { type: 'queue-action'; + datetime: Date; + queueIdx: string; action: string; file: string; - only: string; + args?: string[]; } +const formatTime = (date: Date) => { + return `${date.toLocaleTimeString()}.${date + .getMilliseconds() + .toString() + .padStart(3, '0')}`; +}; + const workingDir = process.cwd(); function replacer(_key: string, value: unknown): unknown { @@ -96,12 +107,18 @@ export const createPerfMeter = ( }; const processedDependencies = new Map(); - const processDependencyEvent = ({ file, only, imports }: IProcessedEvent) => { + const processDependencyEvent = ({ + file, + only, + imports, + fileIdx, + }: IProcessedEvent) => { if (!processedDependencies.has(file)) { processedDependencies.set(file, { exports: [], imports: [], passes: 0, + fileIdx, }); } @@ -112,12 +129,24 @@ export const createPerfMeter = ( }; const queueActions = new Map(); - const processQueueAction = ({ file, action, only }: IQueueActionEvent) => { + const processQueueAction = ({ + file, + action, + args, + queueIdx, + datetime, + }: IQueueActionEvent) => { if (!queueActions.has(file)) { queueActions.set(file, []); } - queueActions.get(file)!.push(`${action}(${only})`); + const stringifiedArgs = + args?.map((arg) => JSON.stringify(arg)).join(', ') ?? ''; + queueActions + .get(file)! + .push( + `${queueIdx}:${action}(${stringifiedArgs})@${formatTime(datetime)}` + ); }; const processSingleEvent = ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 560ddda73..edc74ca06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,6 +380,9 @@ importers: happy-dom: specifier: 10.8.0 version: 10.8.0 + nanoid: + specifier: ^3.3.6 + version: 3.3.6 source-map: specifier: ^0.7.3 version: 0.7.3 @@ -589,15 +592,15 @@ importers: packages/logger: dependencies: debug: - specifier: ^4.1.1 + specifier: ^4.3.4 version: 4.3.4 picocolors: specifier: ^1.0.0 version: 1.0.0 devDependencies: '@types/debug': - specifier: ^4.1.5 - version: 4.1.7 + specifier: ^4.1.8 + version: 4.1.8 '@types/node': specifier: ^17.0.39 version: 17.0.39 @@ -866,6 +869,9 @@ importers: '@swc/core': specifier: ^1.3.20 version: 1.3.20 + debug: + specifier: ^4.3.4 + version: 4.3.4 dedent: specifier: ^0.7.0 version: 0.7.0 @@ -921,6 +927,9 @@ importers: '@types/babel__traverse': specifier: ^7.17.1 version: 7.17.1 + '@types/debug': + specifier: ^4.1.8 + version: 4.1.8 '@types/dedent': specifier: ^0.7.0 version: 0.7.0 @@ -4098,8 +4107,8 @@ packages: '@types/node': 17.0.39 dev: true - /@types/debug@4.1.7: - resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} + /@types/debug@4.1.8: + resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} dependencies: '@types/ms': 0.7.31 dev: true @@ -11929,7 +11938,7 @@ packages: /micromark@3.1.0: resolution: {integrity: sha512-6Mj0yHLdUZjHnOPgr5xfWIMqMWS12zDN6iws9SLuSz76W8jTtAv24MN4/CL7gJrl5vtxGInkkqDv/JIoRsQOvA==} dependencies: - '@types/debug': 4.1.7 + '@types/debug': 4.1.8 debug: 4.3.4 decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.0.6 @@ -12212,6 +12221,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + /nanomatch@1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -13135,7 +13149,7 @@ packages: resolution: {integrity: sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.4 + nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2 @@ -13143,7 +13157,7 @@ packages: resolution: {integrity: sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.4 + nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2