diff --git a/.changeset/pink-moose-cheer.md b/.changeset/pink-moose-cheer.md new file mode 100644 index 000000000..2ecc7ace3 --- /dev/null +++ b/.changeset/pink-moose-cheer.md @@ -0,0 +1,7 @@ +--- +'@linaria/babel-preset': patch +'@linaria/testkit': patch +'@linaria/utils': patch +--- + +In some cases, an asynchronous resolver could cause race conditions. Fixed. diff --git a/.eslintrc.js b/.eslintrc.js index 782accb25..d0a633893 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,67 +59,36 @@ const memberOrder = [ 'constructor', - // Getters - 'public-static-get', - 'protected-static-get', - 'private-static-get', - '#private-static-get', + // Getters & Setters + ['public-static-get', 'public-static-set'], + ['protected-static-get', 'protected-static-set'], + ['private-static-get', 'private-static-set'], + ['#private-static-get', '#private-static-set'], - 'public-decorated-get', - 'protected-decorated-get', - 'private-decorated-get', + ['public-decorated-get', 'public-decorated-set'], + ['protected-decorated-get', 'protected-decorated-set'], + ['private-decorated-get', 'private-decorated-set'], - 'public-instance-get', - 'protected-instance-get', - 'private-instance-get', - '#private-instance-get', + ['public-instance-get', 'public-instance-set'], + ['protected-instance-get', 'protected-instance-set'], + ['private-instance-get', 'private-instance-set'], + ['#private-instance-get', '#private-instance-set'], - 'public-abstract-get', - 'protected-abstract-get', + ['public-abstract-get', 'public-abstract-set'], + ['protected-abstract-get', 'protected-abstract-set'], - 'public-get', - 'protected-get', - 'private-get', - '#private-get', + ['public-get', 'public-set'], + ['protected-get', 'protected-set'], + ['private-get', 'private-set'], + ['#private-get', '#private-set'], - 'static-get', - 'instance-get', - 'abstract-get', + ['static-get', 'static-set'], + ['instance-get', 'instance-set'], + ['abstract-get', 'abstract-set'], - 'decorated-get', + ['decorated-get', 'decorated-set'], - 'get', - - // Setters - 'public-static-set', - 'protected-static-set', - 'private-static-set', - '#private-static-set', - - 'public-decorated-set', - 'protected-decorated-set', - 'private-decorated-set', - - 'public-instance-set', - 'protected-instance-set', - 'private-instance-set', - '#private-instance-set', - - 'public-abstract-set', - 'protected-abstract-set', - - 'public-set', - 'protected-set', - 'private-set', - '#private-set', - - 'static-set', - 'instance-set', - 'abstract-set', - - 'decorated-set', - - 'set', + ['get', 'set'], // Methods 'public-static-method', diff --git a/.gitignore b/.gitignore index 8078fe5a9..493187f28 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ logs npm-debug.log* yarn-debug.log* yarn-error.log* -**/linaria-debug.json +**/linaria-debug # Runtime data pids diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 84f3772f4..72ec56043 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -208,7 +208,7 @@ module.exports = { return false; } - return /\b(?:export|import)\b/m.test(code); + return /(?:^|\*\/|;)\s*(?:export|import)\s/m.test(code); }, action: require.resolve('@linaria/shaker'), } diff --git a/examples/vite/.linariarc.mjs b/examples/vite/.linariarc.mjs index f25185848..3124e1a37 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+/m.test(code); + return /(?:^|\*\/|;)\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 226065ecb..8f000f232 100644 --- a/packages/babel/jest.config.js +++ b/packages/babel/jest.config.js @@ -3,4 +3,12 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.spec.json', + }, + ], + }, }; diff --git a/packages/babel/package.json b/packages/babel/package.json index 1d33968e6..327d6d77a 100644 --- a/packages/babel/package.json +++ b/packages/babel/package.json @@ -56,10 +56,9 @@ "@types/babel__helper-module-imports": "^7.18.0", "@types/babel__template": "^7.4.1", "@types/babel__traverse": "^7.20.1", - "@types/dedent": "^0.7.0", "@types/jest": "^28.1.0", "@types/node": "^17.0.39", - "dedent": "^0.7.0", + "dedent": "^1.5.1", "jest": "^29.6.2", "strip-ansi": "^5.2.0", "ts-jest": "^29.1.1", diff --git a/packages/babel/src/evaluators/index.ts b/packages/babel/src/evaluators/index.ts index d965c9887..9bd401f90 100644 --- a/packages/babel/src/evaluators/index.ts +++ b/packages/babel/src/evaluators/index.ts @@ -6,11 +6,16 @@ import type { TransformCacheCollection } from '../cache'; import Module from '../module'; import type { Entrypoint } from '../transform/Entrypoint'; +export interface IEvaluateResult { + dependencies: string[]; + value: Record; +} + export default function evaluate( cache: TransformCacheCollection, entrypoint: Entrypoint -) { - using m = new Module(entrypoint, cache); +): IEvaluateResult { + const m = new Module(entrypoint, cache); m.evaluate(); diff --git a/packages/babel/src/index.ts b/packages/babel/src/index.ts index daff8eceb..85c61ceb3 100644 --- a/packages/babel/src/index.ts +++ b/packages/babel/src/index.ts @@ -19,6 +19,10 @@ export { } from './utils/withLinariaMetadata'; export { default as Module, DefaultModuleImplementation } from './module'; export { default as transform } from './transform'; +export { + isUnprocessedEntrypointError, + UnprocessedEntrypointError, +} from './transform/actions/UnprocessedEntrypointError'; export * from './types'; export { EvaluatedEntrypoint } from './transform/EvaluatedEntrypoint'; export type { IEvaluatedEntrypoint } from './transform/EvaluatedEntrypoint'; diff --git a/packages/babel/src/module.ts b/packages/babel/src/module.ts index f951944ff..c2bef3d56 100644 --- a/packages/babel/src/module.ts +++ b/packages/babel/src/module.ts @@ -26,9 +26,7 @@ import { Entrypoint } from './transform/Entrypoint'; import { getStack, isSuperSet } from './transform/Entrypoint.helpers'; import type { IEntrypointDependency } from './transform/Entrypoint.types'; import type { IEvaluatedEntrypoint } from './transform/EvaluatedEntrypoint'; -import { syncActionRunner } from './transform/actions/actionRunner'; -import { baseProcessingHandlers } from './transform/generators/baseProcessingHandlers'; -import { syncResolveImports } from './transform/generators/resolveImports'; +import { isUnprocessedEntrypointError } from './transform/actions/UnprocessedEntrypointError'; import loadLinariaOptions from './transform/helpers/loadLinariaOptions'; import { withDefaultServices } from './transform/helpers/withDefaultServices'; import { createVmContext } from './vm/createVmContext'; @@ -106,13 +104,7 @@ function resolve( return resolved; } -function assertDisposed( - entrypoint: Entrypoint | null -): asserts entrypoint is Entrypoint { - invariant(entrypoint, 'Module is disposed'); -} - -class Module implements Disposable { +class Module { public readonly callstack: string[] = []; public readonly debug: Debugger; @@ -186,7 +178,7 @@ class Module implements Disposable { return entrypoint.exports; } - using m = new Module(entrypoint, this.cache, this); + const m = this.createChild(entrypoint); m.evaluate(); return entrypoint.exports; @@ -199,7 +191,7 @@ class Module implements Disposable { public resolve = resolve.bind(this); - protected entrypoint: Entrypoint | null; + #entrypointRef: WeakRef; constructor( entrypoint: Entrypoint, @@ -207,7 +199,7 @@ class Module implements Disposable { parentModule?: Module, private moduleImpl: HiddenModuleMembers = DefaultModuleImplementation ) { - this.entrypoint = entrypoint; + this.#entrypointRef = new WeakRef(entrypoint); this.idx = entrypoint.idx; this.id = entrypoint.name; this.filename = entrypoint.name; @@ -228,42 +220,37 @@ class Module implements Disposable { } public get exports() { - assertDisposed(this.entrypoint); return this.entrypoint.exports; } public set exports(value) { - assertDisposed(this.entrypoint); - this.entrypoint.exports = value; this.debug('the whole exports was overridden with %O', value); } - [Symbol.dispose](): void { - assertDisposed(this.entrypoint); - - this.debug('dispose'); - - this.entrypoint = null; + protected get entrypoint(): Entrypoint { + const entrypoint = this.#entrypointRef.deref(); + invariant(entrypoint, `Module ${this.idx} is disposed`); + return entrypoint; } evaluate(): void { - assertDisposed(this.entrypoint); - const { entrypoint } = this; + entrypoint.assertTransformed(); + + const cached = this.cache.get('entrypoints', entrypoint.name)!; + let evaluatedCreated = false; if (!entrypoint.supersededWith) { this.cache.add( 'entrypoints', entrypoint.name, entrypoint.createEvaluated() ); + evaluatedCreated = true; } - const source = - entrypoint.transformedCode ?? - entrypoint.originalCode ?? - entrypoint.initialCode; + const source = entrypoint.transformedCode; if (!source) { this.debug(`evaluate`, 'there is nothing to evaluate'); @@ -310,6 +297,16 @@ class Module implements Disposable { script.runInContext(context); } catch (e) { + this.isEvaluated = false; + if (evaluatedCreated) { + this.cache.add('entrypoints', entrypoint.name, cached); + } + + if (isUnprocessedEntrypointError(e)) { + // It will be handled by evalFile scenario + throw e; + } + if (e instanceof EvalError) { this.debug('%O', e); @@ -330,8 +327,6 @@ class Module implements Disposable { only: string[], log: Debugger ): Entrypoint | IEvaluatedEntrypoint | null { - assertDisposed(this.entrypoint); - const extension = path.extname(filename); if (extension !== '.json' && !this.extensions.includes(extension)) { return null; @@ -397,14 +392,6 @@ class Module implements Disposable { }, }); - const syncResolve = (what: string, importer: string): string => { - return this.moduleImpl._resolveFilename(what, { - id: importer, - filename: importer, - paths: this.moduleImpl._nodeModulePaths(path.dirname(importer)), - }); - }; - const pluginOptions = loadLinariaOptions({}); const code = fs.readFileSync(filename, 'utf-8'); const newEntrypoint = Entrypoint.createRoot( @@ -427,20 +414,10 @@ class Module implements Disposable { return newEntrypoint; } - const action = newEntrypoint.createAction('processEntrypoint', undefined); - syncActionRunner(action, { - ...baseProcessingHandlers, - resolveImports() { - return syncResolveImports.call(this, syncResolve); - }, - }); - return newEntrypoint; } resolveDependency = (id: string): IEntrypointDependency => { - assertDisposed(this.entrypoint); - const cached = this.entrypoint.getDependency(id); invariant(!(cached instanceof Promise), 'Dependency is not resolved yet'); @@ -489,6 +466,10 @@ class Module implements Disposable { added.forEach((ext) => delete extensions[ext]); } }; + + protected createChild(entrypoint: Entrypoint): Module { + return new Module(entrypoint, this.cache, this, this.moduleImpl); + } } export default Module; diff --git a/packages/babel/src/plugins/preeval.ts b/packages/babel/src/plugins/preeval.ts index 6f2f30943..dc8aaa0b6 100644 --- a/packages/babel/src/plugins/preeval.ts +++ b/packages/babel/src/plugins/preeval.ts @@ -24,8 +24,6 @@ export type PreevalOptions = Pick< 'classNameSlug' | 'displayName' | 'evaluate' | 'features' > & { eventEmitter: EventEmitter }; -const onFinishCallbacks = new WeakMap void>(); - export default function preeval( babel: Core, { eventEmitter = EventEmitter.dummy, ...options }: PreevalOptions @@ -42,51 +40,34 @@ export default function preeval( const rootScope = file.scope; this.processors = []; - 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.perf('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); + }); + }, + }); + }); if ( isFeatureEnabled(options.features, 'dangerousCodeRemover', filename) ) { log('start', 'Strip all JSX and browser related stuff'); - eventEmitter.pair( - { - method: 'queue:transform:preeval:removeDangerousCode', - }, - () => removeDangerousCode(file.path) + eventEmitter.perf('transform:preeval:removeDangerousCode', () => + removeDangerousCode(file.path) ); } - - onFinishCallbacks.set( - this, - eventEmitter.pair({ - method: 'queue:transform:preeval:rest-transformations', - }) - ); }, visitor: {}, post(file: BabelFile) { - onFinishCallbacks.get(this)?.(); - const log = createCustomDebug('preeval', getFileIdx(file.opts.filename!)); invalidateTraversalCache(file.path); diff --git a/packages/babel/src/transform/BaseEntrypoint.ts b/packages/babel/src/transform/BaseEntrypoint.ts index 3d871afc8..998fa4d16 100644 --- a/packages/babel/src/transform/BaseEntrypoint.ts +++ b/packages/babel/src/transform/BaseEntrypoint.ts @@ -23,7 +23,7 @@ const isProxy = ( ): obj is { [VALUES]: Record } => typeof obj === 'object' && obj !== null && VALUES in obj; -const createExports = (log: Debugger) => { +export const createExports = (log: Debugger) => { let exports: Record = {}; const lazyFields = new Set(); @@ -140,6 +140,8 @@ const createExports = (log: Debugger) => { const EXPORTS = Symbol('exports'); +let entrypointSeqId = 0; + export abstract class BaseEntrypoint { public static createExports = createExports; @@ -147,6 +149,9 @@ export abstract class BaseEntrypoint { public readonly log: Debugger; + // eslint-disable-next-line no-plusplus + public readonly seqId = entrypointSeqId++; + readonly #exports: Record; protected constructor( @@ -160,19 +165,33 @@ export abstract class BaseEntrypoint { ) { this.idx = getIdx(name); this.log = - parent?.log.extend(this.idx, '->') ?? services.log.extend(this.idx); + parent?.log.extend(this.ref, '->') ?? services.log.extend(this.ref); + let isExportsInherited = false; if (exports) { if (isProxy(exports)) { this.#exports = exports; + isExportsInherited = true; } else { this.#exports = createExports(this.log); this.#exports[EXPORTS] = exports; } this.exports = exports; } else { - this.#exports = createExports(this.log); + this.#exports = BaseEntrypoint.createExports(this.log); } + + services.eventEmitter.entrypointEvent(this.seqId, { + class: this.constructor.name, + evaluatedOnly: this.evaluatedOnly, + filename: name, + generation, + idx: this.idx, + isExportsInherited, + only, + parentId: parent?.seqId ?? null, + type: 'created', + }); } public get exports(): Record { @@ -190,4 +209,12 @@ export abstract class BaseEntrypoint { this.#exports[EXPORTS] = value; } } + + public get ref() { + return `${this.idx}#${this.generation}`; + } + + protected get exportsProxy() { + return this.#exports; + } } diff --git a/packages/babel/src/transform/Entrypoint.helpers.ts b/packages/babel/src/transform/Entrypoint.helpers.ts index dcf254e91..3a591d6fe 100644 --- a/packages/babel/src/transform/Entrypoint.helpers.ts +++ b/packages/babel/src/transform/Entrypoint.helpers.ts @@ -157,7 +157,9 @@ export function loadAndParse( ); return { - code: undefined, + get code() { + return loadedCode ?? readFileSync(name, 'utf-8'); + }, evaluator: 'ignored', reason: 'extension', }; @@ -171,9 +173,30 @@ export function loadAndParse( code ); + let ast: File | undefined; + + const { evalConfig, parseConfig } = buildConfigs( + services, + name, + pluginOptions, + babelOptions + ); + + const getOrParse = () => { + if (ast) return ast; + ast = eventEmitter.perf('parseFile', () => + parseFile(babel, name, code, parseConfig) + ); + + return ast; + }; + if (action === 'ignore') { log('[createEntrypoint] %s is ignored by rule', name); return { + get ast() { + return getOrParse(); + }, code, evaluator: 'ignored', reason: 'rule', @@ -189,19 +212,10 @@ export function loadAndParse( }) ).default; - const { evalConfig, parseConfig } = buildConfigs( - services, - name, - pluginOptions, - babelOptions - ); - - const ast: File = eventEmitter.pair({ method: 'parseFile' }, () => - parseFile(babel, name, code, parseConfig) - ); - return { - ast, + get ast() { + return getOrParse(); + }, code, evaluator, evalConfig, diff --git a/packages/babel/src/transform/Entrypoint.ts b/packages/babel/src/transform/Entrypoint.ts index bf1657d3b..de39c9b11 100644 --- a/packages/babel/src/transform/Entrypoint.ts +++ b/packages/babel/src/transform/Entrypoint.ts @@ -12,8 +12,10 @@ import type { IIgnoredEntrypoint, } from './Entrypoint.types'; import { EvaluatedEntrypoint } from './EvaluatedEntrypoint'; +import { AbortError } from './actions/AbortError'; import type { ActionByType } from './actions/BaseAction'; import { BaseAction } from './actions/BaseAction'; +import { UnprocessedEntrypointError } from './actions/UnprocessedEntrypointError'; import type { Services, ActionTypes, ActionQueueItem } from './types'; const EMPTY_FILE = '=== empty file ==='; @@ -31,8 +33,6 @@ function ancestorOrSelf(name: string, parent: ParentEntrypoint) { return null; } -type DependencyType = IEntrypointDependency | Promise; - export class Entrypoint extends BaseEntrypoint { public readonly evaluated = false; @@ -62,7 +62,11 @@ export class Entrypoint extends BaseEntrypoint { exports: Record | undefined, evaluatedOnly: string[], loadedAndParsed?: IEntrypointCode | IIgnoredEntrypoint, - protected readonly dependencies = new Map(), + protected readonly resolveTasks = new Map< + string, + Promise + >(), + protected readonly dependencies = new Map(), generation = 1 ) { super(services, evaluatedOnly, exports, generation, name, only, parent); @@ -97,16 +101,14 @@ export class Entrypoint extends BaseEntrypoint { return this.loadedAndParsed.code; } - public get ref() { - return `${this.idx}#${this.generation}`; - } - public get supersededWith(): Entrypoint | null { return this.#supersededWith?.supersededWith ?? this.#supersededWith; } - public get transformedCode() { - return this.#transformResultCode; + public get transformedCode(): string | null { + return ( + this.#transformResultCode ?? this.supersededWith?.transformedCode ?? null + ); } public static createRoot( @@ -150,7 +152,7 @@ export class Entrypoint extends BaseEntrypoint { pluginOptions: StrictOptions ): Entrypoint | 'loop' { const { cache, eventEmitter } = services; - return eventEmitter.pair({ method: 'createEntrypoint' }, () => { + return eventEmitter.perf('createEntrypoint', () => { const [status, entrypoint] = Entrypoint.innerCreate( services, parent @@ -159,6 +161,7 @@ export class Entrypoint extends BaseEntrypoint { log: parent.log, name: parent.name, parent: parent.parent, + seqId: parent.seqId, } : null, name, @@ -236,6 +239,7 @@ export class Entrypoint extends BaseEntrypoint { exports, evaluatedOnly, undefined, + cached && 'resolveTasks' in cached ? cached.resolveTasks : undefined, cached && 'dependencies' in cached ? cached.dependencies : undefined, cached ? cached.generation + 1 : 1 ); @@ -248,8 +252,30 @@ export class Entrypoint extends BaseEntrypoint { return ['created', newEntrypoint]; } - public addDependency(name: string, dependency: DependencyType): void { - this.dependencies.set(name, dependency); + public addDependency(dependency: IEntrypointDependency): void { + this.resolveTasks.delete(dependency.source); + this.dependencies.set(dependency.source, dependency); + } + + public addResolveTask( + name: string, + dependency: Promise + ): void { + this.resolveTasks.set(name, dependency); + } + + public assertNotSuperseded() { + if (this.supersededWith) { + this.log('superseded'); + throw new AbortError('superseded'); + } + } + + public assertTransformed() { + if (this.transformedCode === null) { + this.log('not transformed'); + throw new UnprocessedEntrypointError(this.supersededWith ?? this); + } } public createAction< @@ -280,6 +306,12 @@ export class Entrypoint extends BaseEntrypoint { cache.set(data, newAction); + this.services.eventEmitter.entrypointEvent(this.seqId, { + type: 'actionCreated', + actionType, + actionIdx: newAction.idx, + }); + return newAction; } @@ -305,7 +337,7 @@ export class Entrypoint extends BaseEntrypoint { return new EvaluatedEntrypoint( this.services, evaluatedOnly, - this.exports, + this.exportsProxy, this.generation + 1, this.name, this.only, @@ -313,10 +345,16 @@ export class Entrypoint extends BaseEntrypoint { ); } - public getDependency(name: string): DependencyType | undefined { + public getDependency(name: string): IEntrypointDependency | undefined { return this.dependencies.get(name); } + public getResolveTask( + name: string + ): Promise | undefined { + return this.resolveTasks.get(name); + } + public hasLinariaMetadata() { return this.#hasLinariaMetadata; } @@ -340,6 +378,11 @@ export class Entrypoint extends BaseEntrypoint { public setTransformResult(res: ITransformFileResult | null) { this.#hasLinariaMetadata = Boolean(res?.metadata); this.#transformResultCode = res?.code ?? null; + + this.services.eventEmitter.entrypointEvent(this.seqId, { + isNull: res === null, + type: 'setTransformResult', + }); } private supersede(newOnlyOrEntrypoint: string[] | Entrypoint): Entrypoint { @@ -356,10 +399,15 @@ export class Entrypoint extends BaseEntrypoint { this.exports, this.evaluatedOnly, this.loadedAndParsed, + this.resolveTasks, this.dependencies, this.generation + 1 ); + this.services.eventEmitter.entrypointEvent(this.seqId, { + type: 'superseded', + with: newEntrypoint.seqId, + }); this.log( 'superseded by %s (%o -> %o)', newEntrypoint.name, diff --git a/packages/babel/src/transform/Entrypoint.types.ts b/packages/babel/src/transform/Entrypoint.types.ts index b2f3f266f..367b54a01 100644 --- a/packages/babel/src/transform/Entrypoint.types.ts +++ b/packages/babel/src/transform/Entrypoint.types.ts @@ -7,14 +7,15 @@ import type { Evaluator, StrictOptions } from '@linaria/utils'; import type { Services } from './types'; export interface IEntrypointCode { - ast: File; + readonly ast: File; code: string; evalConfig: TransformOptions; evaluator: Evaluator; } export interface IIgnoredEntrypoint { - code?: string; + readonly ast?: File; + readonly code?: string; evaluator: 'ignored'; reason: 'extension' | 'rule'; } diff --git a/packages/babel/src/transform/__tests__/createExports.test.ts b/packages/babel/src/transform/__tests__/createExports.test.ts new file mode 100644 index 000000000..b1bc4decc --- /dev/null +++ b/packages/babel/src/transform/__tests__/createExports.test.ts @@ -0,0 +1,75 @@ +import { linariaLogger } from '@linaria/logger'; + +import { createExports } from '../BaseEntrypoint'; + +describe('createExports', () => { + it('should be defined', () => { + expect(createExports).toBeDefined(); + }); + + it('should create exports object', () => { + const exports = createExports(linariaLogger); + expect(exports).toBeDefined(); + }); + + it('should set and get value', () => { + const exports = createExports(linariaLogger); + exports.foo = 'bar'; + expect(exports.foo).toBe('bar'); + }); + + it('should set and get value with defineProperty', () => { + const exports = createExports(linariaLogger); + Object.defineProperty(exports, 'foo', { + value: 'bar', + }); + expect(exports.foo).toBe('bar'); + }); + + it('should set and get value with defineProperty and getter', () => { + const exports = createExports(linariaLogger); + Object.defineProperty(exports, 'foo', { + get: () => 'bar', + }); + expect(exports.foo).toBe('bar'); + }); + + it('should not override value with undefined', () => { + const exports = createExports(linariaLogger); + exports.foo = 'bar'; + exports.foo = undefined; + expect(exports.foo).toBe('bar'); + }); + + it('should not override value with defineProperty and undefined', () => { + const exports = createExports(linariaLogger); + exports.foo = 'bar'; + Object.defineProperty(exports, 'foo', { + value: undefined, + }); + expect(exports.foo).toBe('bar'); + }); + + it('should not override value with defineProperty and getter returning undefined', () => { + const exports = createExports(linariaLogger); + exports.foo = 'bar'; + Object.defineProperty(exports, 'foo', { + get: () => undefined, + }); + + expect(exports.foo).toBe('bar'); + }); + + it('should not override lazy value with defineProperty and getter returning undefined', () => { + const exports = createExports(linariaLogger); + Object.defineProperty(exports, 'foo', { + get: () => 'bar', + }); + + Object.defineProperty(exports, 'foo', { + get: () => undefined, + }); + + expect(exports.foo).toBe('bar'); + }); +}); diff --git a/packages/babel/src/transform/actions/BaseAction.ts b/packages/babel/src/transform/actions/BaseAction.ts index 7e6317c99..c41c9448f 100644 --- a/packages/babel/src/transform/actions/BaseAction.ts +++ b/packages/babel/src/transform/actions/BaseAction.ts @@ -1,16 +1,19 @@ /* eslint-disable no-plusplus */ +import '../../utils/dispose-polyfill'; +import type { Debugger } from '@linaria/logger'; + import type { Entrypoint } from '../Entrypoint'; import type { ActionQueueItem, ActionTypes, AnyIteratorResult, AsyncScenarioForAction, + Handler, IBaseAction, Services, + SyncScenarioForAction, TypeOfResult, YieldResult, - SyncScenarioForAction, - Handler, } from '../types'; import { Pending } from '../types'; @@ -41,6 +44,8 @@ export class BaseAction | AsyncScenarioForAction | null = null; + private activeScenarioError?: unknown; + private activeScenarioNextResults: AnyIteratorResult< 'async' | 'sync', TypeOfResult @@ -57,6 +62,36 @@ export class BaseAction this.idx = actionIdx.toString(16).padStart(6, '0'); } + public get log(): Debugger { + return this.entrypoint.log.extend(this.ref); + } + + public get ref() { + return `${this.type}@${this.idx}`; + } + + public createAbortSignal(): AbortSignal & Disposable { + const abortController = new AbortController(); + + const unsubscribeFromParentAbort = this.onAbort(() => { + this.entrypoint.log('parent aborted'); + abortController.abort(); + }); + + const unsubscribeFromSupersede = this.entrypoint.onSupersede(() => { + this.entrypoint.log('entrypoint superseded, aborting processing'); + abortController.abort(); + }); + + const abortSignal = abortController.signal as AbortSignal & Disposable; + abortSignal[Symbol.dispose] = () => { + unsubscribeFromParentAbort(); + unsubscribeFromSupersede(); + }; + + return abortSignal; + } + public *getNext< TNextType extends ActionTypes, TNextAction extends ActionByType = ActionByType, @@ -78,6 +113,14 @@ export class BaseAction ]) as TypeOfResult; } + public onAbort(fn: () => void): () => void { + this.abortSignal?.addEventListener('abort', fn); + + return () => { + this.abortSignal?.removeEventListener('abort', fn); + }; + } + public run< TMode extends 'async' | 'sync', THandler extends Handler = Handler, @@ -91,29 +134,74 @@ export class BaseAction let nextIdx = 0; - const processError = (e: unknown) => { - const nextResult = this.activeScenario!.throw(e); - this.activeScenarioNextResults.push(nextResult as IterationResult); + const throwFn = (e: unknown) => + this.emitAction(nextIdx, () => this.activeScenario!.throw(e)); + + const nextFn = (arg: YieldResult) => + this.emitAction(nextIdx, () => this.activeScenario!.next(arg)); + + const processNextResult = ( + result: IterationResult, + onError?: (e: unknown) => void + ) => { + if ('then' in result) { + result.then((r) => { + if (r.done) { + this.result = r.value; + } + }, onError); + } else if (result.done) { + this.result = result.value; + } + + this.activeScenarioNextResults.push(result); }; - const processNext = (arg: YieldResult) => { + const processError = (e: unknown) => { if (this.activeScenarioNextResults.length > nextIdx) { + this.log( + 'error was already handled in another branch, result idx is %d', + nextIdx + ); return; } + this.log('error processing, result idx is %d', nextIdx); + try { - const nextResult = this.activeScenario!.next(arg); - if ('then' in nextResult) { - nextResult.then((result) => { - if (result.done) { - this.result = result.value; - } - }, processError); - } else if (nextResult.done) { - this.result = nextResult.value; + const nextResult = throwFn(e); + processNextResult(nextResult as IterationResult, processError); + } catch (errorInGenerator) { + const { recover } = handler; + if (recover) { + const nextResult = { + done: false, + value: recover(errorInGenerator, this), + }; + + processNextResult(nextResult as IterationResult, processError); + return; } - this.activeScenarioNextResults.push(nextResult as IterationResult); + this.activeScenarioError = errorInGenerator; + throw errorInGenerator; + } + }; + + const processNext = (arg: YieldResult) => { + if (this.activeScenarioNextResults.length > nextIdx) { + this.log( + 'next was already handled in another branch, result idx is %d', + nextIdx + ); + return; + } + + this.log('next processing, result idx is %d', nextIdx); + + try { + const nextResult = nextFn(arg); + processNextResult(nextResult as IterationResult, processError); } catch (e) { processError(e); } @@ -121,13 +209,38 @@ export class BaseAction return { next: (arg: YieldResult): IterationResult => { + this.rethrowActiveScenarioError(); processNext(arg); return this.activeScenarioNextResults[nextIdx++] as IterationResult; }, throw: (e: unknown): IterationResult => { + this.rethrowActiveScenarioError(); processError(e); return this.activeScenarioNextResults[nextIdx++] as IterationResult; }, }; } + + protected emitAction(yieldIdx: number, fn: () => TRes) { + return this.services.eventEmitter.action( + this.type, + `${this.idx}:${yieldIdx + 1}`, + this.entrypoint.ref, + fn + ); + } + + private rethrowActiveScenarioError() { + if (!this.activeScenarioError) { + return; + } + + this.log( + 'scenario has an unhandled error from another branch, rethrow %o', + this.activeScenarioError + ); + + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw this.activeScenarioError; + } } diff --git a/packages/babel/src/transform/actions/UnprocessedEntrypointError.ts b/packages/babel/src/transform/actions/UnprocessedEntrypointError.ts new file mode 100644 index 000000000..0020d3d2a --- /dev/null +++ b/packages/babel/src/transform/actions/UnprocessedEntrypointError.ts @@ -0,0 +1,14 @@ +import type { Entrypoint } from '../Entrypoint'; + +export class UnprocessedEntrypointError extends Error { + constructor(public entrypoint: Entrypoint) { + super( + `Entrypoint ${entrypoint.idx} is not processed and can't be evaluated` + ); + } +} + +export const isUnprocessedEntrypointError = ( + value: unknown +): value is UnprocessedEntrypointError => + value instanceof UnprocessedEntrypointError; diff --git a/packages/babel/src/transform/actions/__tests__/BaseAction.test.ts b/packages/babel/src/transform/actions/__tests__/BaseAction.test.ts index dcca06df0..91ae63e9f 100644 --- a/packages/babel/src/transform/actions/__tests__/BaseAction.test.ts +++ b/packages/babel/src/transform/actions/__tests__/BaseAction.test.ts @@ -8,6 +8,8 @@ import type { ITransformAction, Services, Handler } from '../../types'; import { BaseAction } from '../BaseAction'; describe('BaseAction', () => { + const emptyResult = { code: '', metadata: null }; + let services: Services; let entrypoint: Entrypoint; @@ -42,7 +44,7 @@ describe('BaseAction', () => { it('run should return generator-like object', () => { const generator = action.run(function* dummy() { - return null; + return emptyResult; }); expect(generator.next).toBeDefined(); expect(generator.throw).toBeDefined(); @@ -90,7 +92,7 @@ describe('BaseAction', () => { onError(e); } - return null; + return emptyResult; }; const generator = action.run(handler); @@ -101,7 +103,10 @@ describe('BaseAction', () => { }); const error = new Error('foo'); - expect(generator.throw(error)).toEqual({ done: true, value: null }); + expect(generator.throw(error)).toEqual({ + done: true, + value: emptyResult, + }); expect(onError).toHaveBeenCalledWith(error); }); @@ -115,7 +120,7 @@ describe('BaseAction', () => { yield ['processImports', entrypoint, { resolved: [] }, null]; - return null; + return emptyResult; }; const generator = action.run(handler); @@ -134,7 +139,7 @@ describe('BaseAction', () => { expect(generator.next()).toEqual({ done: true, - value: null, + value: emptyResult, }); }); @@ -146,7 +151,7 @@ describe('BaseAction', () => { onError(e); } - return null; + return emptyResult; }; const generator1 = action.run(handler); @@ -158,7 +163,10 @@ describe('BaseAction', () => { }); const error = new Error('foo'); - expect(generator1.throw(error)).toEqual({ done: true, value: null }); + expect(generator1.throw(error)).toEqual({ + done: true, + value: emptyResult, + }); expect(onError).toHaveBeenCalledWith(error); expect(generator2.next()).toEqual({ @@ -166,8 +174,139 @@ describe('BaseAction', () => { value: ['resolveImports', entrypoint, { imports: new Map() }, null], }); - expect(generator2.next()).toEqual({ done: true, value: null }); + expect(generator2.next()).toEqual({ done: true, value: emptyResult }); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("should rethrow error from every run if the first one didn't catch it", () => { + const error = new Error('foo'); + + const handler: Handler<'sync', ITransformAction> = function* handler() { + throw error; + }; + + const generator1 = action.run(handler); + const generator2 = action.run(handler); + + expect(() => generator1.next()).toThrow(error); + expect(() => generator2.next()).toThrow(error); + }); + + it('should process parallel throws', () => { + const handler: Handler<'sync', ITransformAction> = function* handler() { + try { + yield ['resolveImports', entrypoint, { imports: new Map() }, null]; + } catch (e) { + onError(e); + } + + return emptyResult; + }; + + const generator1 = action.run(handler); + const generator2 = action.run(handler); + + expect(generator1.next()).toEqual({ + done: false, + value: ['resolveImports', entrypoint, { imports: new Map() }, null], + }); + expect(generator2.next()).toEqual({ + done: false, + value: ['resolveImports', entrypoint, { imports: new Map() }, null], + }); + + const error1 = new Error('foo'); + const error2 = new Error('bar'); + expect(generator1.throw(error1)).toEqual({ + done: true, + value: emptyResult, + }); + expect(generator2.throw(error2)).toEqual({ + done: true, + value: emptyResult, + }); expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error1); + }); + + it('should cache results of async generators', async () => { + const wait = () => + new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 100); + }); + + const handler: Handler<'async', ITransformAction> = + async function* handler() { + await wait(); + yield ['resolveImports', entrypoint, { imports: new Map() }, null]; + + return { code: 'bar', metadata: null }; + }; + + const generator1 = action.run(handler); + const generator2 = action.run(handler); + + const gen1Next = await generator1.next(); + expect(gen1Next).toEqual({ + done: false, + value: ['resolveImports', entrypoint, { imports: new Map() }, null], + }); + + expect(await generator2.next()).toEqual(gen1Next); + + const gen1Result = await generator1.next(); + expect(gen1Result).toEqual({ + done: true, + value: { code: 'bar', metadata: null }, + }); + + expect(await generator2.next()).toEqual(gen1Result); + }); + + it('should cache errors of async generators', async () => { + const wait = () => + new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 100); + }); + + const handler: Handler<'async', ITransformAction> = + async function* handler() { + await wait(); + yield ['resolveImports', entrypoint, { imports: new Map() }, null]; + + throw new Error('foo'); + }; + + const generator1 = action.run(handler); + const generator2 = action.run(handler); + + const gen1Next = await generator1.next(); + expect(gen1Next).toEqual({ + done: false, + value: ['resolveImports', entrypoint, { imports: new Map() }, null], + }); + + expect(await generator2.next()).toEqual(gen1Next); + + let error: unknown; + try { + await generator1.next(); + fail('should throw'); + } catch (e) { + error = e; + expect(e).toBe(error); + } + + try { + await generator2.next(); + fail('should throw'); + } catch (e) { + expect(e).toBe(error); + } }); }); }); diff --git a/packages/babel/src/transform/actions/__tests__/actionRunner.test.ts b/packages/babel/src/transform/actions/__tests__/actionRunner.test.ts index 4d856545f..dba23504a 100644 --- a/packages/babel/src/transform/actions/__tests__/actionRunner.test.ts +++ b/packages/babel/src/transform/actions/__tests__/actionRunner.test.ts @@ -1,6 +1,4 @@ /* eslint-disable require-yield */ -import { enableDebug } from '@linaria/logger'; - import type { IEntrypointDependency } from '../../Entrypoint.types'; import { createEntrypoint, @@ -164,7 +162,7 @@ describe('actionRunner', () => { const abortController = new AbortController(); abortController.abort(); - function* handlerGenerator( + function* workflow( this: IWorkflowAction ): SyncScenarioForAction { yield [ @@ -177,15 +175,24 @@ describe('actionRunner', () => { throw new Error('Should not be reached'); } - handlerGenerator.recover = jest.fn< + const shouldNotBeCalled = jest.fn(); + + function* processEntrypointMock( + this: IProcessEntrypointAction + ): SyncScenarioForAction { + shouldNotBeCalled(); + } + + processEntrypointMock.recover = jest.fn< YieldArg, - [e: unknown, action: BaseAction] + [e: unknown, action: BaseAction] >((e): YieldArg => { throw e; }); const handlers = getHandlers<'sync'>({ - workflow: handlerGenerator, + processEntrypoint: processEntrypointMock, + workflow, }); const entrypoint = createEntrypoint(services, '/foo/bar.js', ['default']); @@ -195,16 +202,23 @@ describe('actionRunner', () => { 'workflow@00001#1' ); - expect(handlerGenerator.recover).toHaveBeenCalled(); + expect(processEntrypointMock.recover).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'workflow@00001#1', + name: 'AbortError', + }), + expect.objectContaining({ type: 'processEntrypoint' }) + ); + expect(shouldNotBeCalled).not.toHaveBeenCalled(); }); it('should recover', () => { - enableDebug(); - const abortController = new AbortController(); abortController.abort(); - function* handlerGenerator( + const shouldBeCalled = jest.fn(); + + function* workflow( this: IWorkflowAction ): SyncScenarioForAction { yield [ @@ -214,25 +228,37 @@ describe('actionRunner', () => { abortController.signal, ]; - throw new Error('Should not be reached'); + shouldBeCalled(); + + return { + code: '', + sourceMap: null, + }; } - handlerGenerator.recover = jest.fn< + function* processEntrypointMock( + this: IProcessEntrypointAction + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): SyncScenarioForAction {} + + processEntrypointMock.recover = jest.fn< YieldArg, - [e: unknown, action: BaseAction] + [e: unknown, action: BaseAction] >((e, action): YieldArg => { return ['processEntrypoint', action.entrypoint, undefined, null]; }); const handlers = getHandlers<'sync'>({ - workflow: handlerGenerator, + processEntrypoint: processEntrypointMock, + workflow, }); const entrypoint = createEntrypoint(services, '/foo/bar.js', ['default']); const action = entrypoint.createAction('workflow', undefined, null); syncActionRunner(action, handlers); - expect(handlerGenerator.recover).toHaveBeenCalled(); + expect(processEntrypointMock.recover).toHaveBeenCalled(); + expect(shouldBeCalled).toHaveBeenCalledTimes(1); }); it('should process triple superseded entrypoint', () => { diff --git a/packages/babel/src/transform/actions/actionRunner.ts b/packages/babel/src/transform/actions/actionRunner.ts index 1854940f4..ffc7028b3 100644 --- a/packages/babel/src/transform/actions/actionRunner.ts +++ b/packages/babel/src/transform/actions/actionRunner.ts @@ -29,57 +29,51 @@ function getHandler< const getActionRef = (type: string, entrypoint: { ref: string }) => `${type}@${entrypoint.ref}`; -function assertIfAborted( - action: BaseAction, - stack: string[] -) { - if (action.abortSignal?.aborted) { - action.entrypoint.log('Action %s was aborted', stack.join('->')); - throw new AbortError(stack[0]); - } -} +const ACTION_ERROR = Symbol('ACTION_ERROR'); +type ActionError = [marker: typeof ACTION_ERROR, err: unknown]; +const isActionError = (e: unknown): e is ActionError => + Array.isArray(e) && e[0] === ACTION_ERROR; export async function asyncActionRunner( action: BaseAction, actionHandlers: Handlers<'async' | 'sync'>, stack: string[] = [getActionRef(action.type, action.entrypoint)] ): Promise> { - assertIfAborted(action, stack); - if (action.result !== Pending) { + action.log('result is cached'); return action.result as TypeOfResult; } const handler = getHandler(action, actionHandlers); const generator = action.run<'async' | 'sync'>(handler); - let result = await generator.next(); - while (!result.done) { + let actionResult: TypeOfResult | ActionError | undefined; + // eslint-disable-next-line no-constant-condition + while (true) { + if (action.abortSignal?.aborted) { + action.log('action is aborted'); + generator.throw(new AbortError(stack[0])); + } + + const result = await (isActionError(actionResult) + ? generator.throw(actionResult[1]) + : generator.next(actionResult)); + if (result.done) { + return result.value as TypeOfResult; + } + const [type, entrypoint, data, abortSignal] = result.value; const nextAction = entrypoint.createAction(type, data, abortSignal); try { - const actionResult = await asyncActionRunner(nextAction, actionHandlers, [ + actionResult = await asyncActionRunner(nextAction, actionHandlers, [ ...stack, getActionRef(type, entrypoint), ]); - result = await generator.next(actionResult); } catch (e) { - if (handler.recover) { - try { - result = { - done: false, - value: handler.recover(e, action), - }; - } catch (errorInRecover) { - result = await generator.throw(errorInRecover); - } - } else { - result = await generator.throw(e); - } + nextAction.log('error', e); + actionResult = [ACTION_ERROR, e]; } } - - return result.value as TypeOfResult; } export function syncActionRunner( @@ -87,40 +81,39 @@ export function syncActionRunner( actionHandlers: Handlers<'sync'>, stack: string[] = [getActionRef(action.type, action.entrypoint)] ): TypeOfResult { - assertIfAborted(action, stack); - if (action.result !== Pending) { + action.log('result is cached'); return action.result as TypeOfResult; } const handler = getHandler(action, actionHandlers); const generator = action.run<'sync'>(handler); - let result = generator.next(); - while (!result.done) { + let actionResult: TypeOfResult | ActionError | undefined; + // eslint-disable-next-line no-constant-condition + while (true) { + if (action.abortSignal?.aborted) { + action.log('action is aborted'); + generator.throw(new AbortError(stack[0])); + } + + const result = isActionError(actionResult) + ? generator.throw(actionResult[1]) + : generator.next(actionResult); + if (result.done) { + return result.value as TypeOfResult; + } + const [type, entrypoint, data, abortSignal] = result.value; const nextAction = entrypoint.createAction(type, data, abortSignal); try { - const actionResult = syncActionRunner(nextAction, actionHandlers, [ + actionResult = syncActionRunner(nextAction, actionHandlers, [ ...stack, getActionRef(type, entrypoint), ]); - result = generator.next(actionResult); } catch (e) { - if (handler.recover) { - try { - result = { - done: false, - value: handler.recover(e, action), - }; - } catch (errorInRecover) { - result = generator.throw(errorInRecover); - } - } else { - result = generator.throw(e); - } + nextAction.log('error', e); + actionResult = [ACTION_ERROR, e]; } } - - return result.value as TypeOfResult; } diff --git a/packages/babel/src/transform/generators/__tests__/processEntrypoint.test.ts b/packages/babel/src/transform/generators/__tests__/processEntrypoint.test.ts index 26e0d225c..ad1332f0a 100644 --- a/packages/babel/src/transform/generators/__tests__/processEntrypoint.test.ts +++ b/packages/babel/src/transform/generators/__tests__/processEntrypoint.test.ts @@ -64,21 +64,17 @@ describe('processEntrypoint', () => { const emittedSignals = emitted.map((a) => a[3]); expect(emittedSignals.map((signal) => signal?.aborted)).toEqual([ - undefined, + false, false, ]); - const newEntrypoint = createEntrypoint(services, '/foo/bar.js', ['named']); + createEntrypoint(services, '/foo/bar.js', ['named']); expect(emittedSignals.map((signal) => signal?.aborted)).toEqual([ - undefined, + true, true, ]); - const nextProcessEntrypoint = gen.next(); - expectIteratorYieldResult(nextProcessEntrypoint); - expect(nextProcessEntrypoint.value[0]).toBe('processEntrypoint'); - expect(nextProcessEntrypoint.value[1]).toBe(newEntrypoint); - + expect(() => gen.next()).toThrow(/superseded/); expectIteratorReturnResult(gen.next(), undefined); }); @@ -103,14 +99,14 @@ describe('processEntrypoint', () => { const emittedSignals = emitted.map((a) => a[3]); expect(emittedSignals.map((signal) => signal?.aborted)).toEqual([ - undefined, + false, false, ]); abortController.abort(); expect(emittedSignals.map((signal) => signal?.aborted)).toEqual([ - undefined, + true, true, ]); diff --git a/packages/babel/src/transform/generators/evalFile.ts b/packages/babel/src/transform/generators/evalFile.ts index 903490a03..6635d3d10 100644 --- a/packages/babel/src/transform/generators/evalFile.ts +++ b/packages/babel/src/transform/generators/evalFile.ts @@ -1,7 +1,9 @@ import type { ValueCache } from '@linaria/tags'; +import type { IEvaluateResult } from '../../evaluators'; import evaluate from '../../evaluators'; import hasLinariaPreval from '../../utils/hasLinariaPreval'; +import { isUnprocessedEntrypointError } from '../actions/UnprocessedEntrypointError'; import type { IEvalAction, SyncScenarioForAction } from '../types'; const wrap = (fn: () => T): T | Error => { @@ -25,7 +27,22 @@ export function* evalFile( log(`>> evaluate __linariaPreval`); - const evaluated = evaluate(this.services.cache, entrypoint); + let evaluated: IEvaluateResult | undefined; + + while (evaluated === undefined) { + try { + evaluated = evaluate(this.services.cache, entrypoint); + } catch (e) { + if (isUnprocessedEntrypointError(e)) { + entrypoint.log( + 'Evaluation has been aborted because one if the required files is not processed. Schedule reprocessing and repeat evaluation.' + ); + yield ['processEntrypoint', e.entrypoint, undefined]; + } else { + throw e; + } + } + } const linariaPreval = hasLinariaPreval(evaluated.value) ? evaluated.value.__linariaPreval diff --git a/packages/babel/src/transform/generators/getExports.ts b/packages/babel/src/transform/generators/getExports.ts index 930306993..a6b2a998d 100644 --- a/packages/babel/src/transform/generators/getExports.ts +++ b/packages/babel/src/transform/generators/getExports.ts @@ -44,7 +44,7 @@ export function* getExports( services: { cache }, } = this; const { loadedAndParsed } = entrypoint; - if (loadedAndParsed.evaluator === 'ignored') { + if (loadedAndParsed.ast === undefined) { return []; } @@ -95,6 +95,8 @@ export function* getExports( } } + entrypoint.log(`exports: %o`, result); + cache.add('exports', entrypoint.name, result); return result; diff --git a/packages/babel/src/transform/generators/processEntrypoint.ts b/packages/babel/src/transform/generators/processEntrypoint.ts index 9a27c1e97..3b930b91d 100644 --- a/packages/babel/src/transform/generators/processEntrypoint.ts +++ b/packages/babel/src/transform/generators/processEntrypoint.ts @@ -18,52 +18,21 @@ export function* processEntrypoint( const { only, log } = this.entrypoint; log('start processing (only: %s)', only); - if (this.entrypoint.supersededWith) { - log('entrypoint already superseded, rescheduling processing'); - yield [ - 'processEntrypoint', - this.entrypoint.supersededWith, - undefined, - null, - ]; - return; - } + this.entrypoint.assertNotSuperseded(); - const abortController = new AbortController(); + using abortSignal = this.createAbortSignal(); - const onParentAbort = () => { - log('parent aborted, aborting processing'); - abortController.abort(); - }; + yield ['explodeReexports', this.entrypoint, undefined, abortSignal]; + const result = yield* this.getNext( + 'transform', + this.entrypoint, + undefined, + abortSignal + ); - if (this.abortSignal) { - this.abortSignal.addEventListener('abort', onParentAbort); - } + this.entrypoint.setTransformResult(result); - const unsubscribe = this.entrypoint.onSupersede(() => { - log('entrypoint superseded, aborting processing'); - abortController.abort(); - }); - - try { - yield ['explodeReexports', this.entrypoint, undefined, null]; - const result = yield* this.getNext( - 'transform', - this.entrypoint, - undefined, - abortController.signal - ); - this.entrypoint.setTransformResult(result); - } finally { - this.abortSignal?.removeEventListener('abort', onParentAbort); - unsubscribe(); - } - - const { supersededWith } = this.entrypoint; - if (supersededWith) { - log('entrypoint superseded, rescheduling processing'); - yield ['processEntrypoint', supersededWith, undefined, null]; - } + this.entrypoint.assertNotSuperseded(); log('entrypoint processing finished'); } diff --git a/packages/babel/src/transform/generators/processImports.ts b/packages/babel/src/transform/generators/processImports.ts index da66e0baf..312eba9cc 100644 --- a/packages/babel/src/transform/generators/processImports.ts +++ b/packages/babel/src/transform/generators/processImports.ts @@ -7,11 +7,14 @@ import type { IProcessImportsAction, SyncScenarioForAction } from '../types'; export function* processImports( this: IProcessImportsAction ): SyncScenarioForAction { - for (const { only, resolved } of this.data.resolved) { + for (const dependency of this.data.resolved) { + const { resolved, only } = dependency; if (!resolved) { continue; } + this.entrypoint.addDependency(dependency); + const nextEntrypoint = this.entrypoint.createChild(resolved, only); if (nextEntrypoint === 'loop' || nextEntrypoint.ignored) { continue; diff --git a/packages/babel/src/transform/generators/resolveImports.ts b/packages/babel/src/transform/generators/resolveImports.ts index 545fecb6c..ed9e8a62d 100644 --- a/packages/babel/src/transform/generators/resolveImports.ts +++ b/packages/babel/src/transform/generators/resolveImports.ts @@ -5,10 +5,10 @@ import type { Entrypoint } from '../Entrypoint'; import { getStack, isSuperSet, mergeOnly } from '../Entrypoint.helpers'; import type { IEntrypointDependency } from '../Entrypoint.types'; import type { + AsyncScenarioForAction, IResolveImportsAction, Services, SyncScenarioForAction, - AsyncScenarioForAction, } from '../types'; function emitDependency( @@ -154,9 +154,18 @@ export async function* asyncResolveImports( const resolvedImports = await Promise.all( listOfImports.map(([source, importsOnly]) => { const cached = entrypoint.getDependency(source); - if (cached instanceof Promise) { + if (cached) { + return { + source, + only: mergeOnly(cached.only, importsOnly), + resolved: cached.resolved, + }; + } + + const task = entrypoint.getResolveTask(source); + if (task) { // If we have cached task, we need to merge only… - const newTask = cached.then((res) => { + const newTask = task.then((res) => { if (isSuperSet(res.only, importsOnly)) { return res; } @@ -166,38 +175,17 @@ export async function* asyncResolveImports( log('merging imports %o and %o: %o', importsOnly, res.only, merged); - entrypoint.addDependency(source, { - only: merged, - resolved: res.resolved, - source, - }); - return { ...res, only: merged }; }); // … and update the cache - entrypoint.addDependency(source, newTask); + entrypoint.addResolveTask(source, newTask); return newTask; } - if (cached) { - const merged = { - source, - only: mergeOnly(cached.only, importsOnly), - resolved: cached.resolved, - }; - - entrypoint.addDependency(source, merged); - - return merged; - } - - const resolveTask = getResolveTask(source, importsOnly).then((res) => { - entrypoint.addDependency(source, res); - return res; - }); + const resolveTask = getResolveTask(source, importsOnly); - entrypoint.addDependency(source, resolveTask); + entrypoint.addResolveTask(source, resolveTask); return resolveTask; }) diff --git a/packages/babel/src/transform/generators/transform.ts b/packages/babel/src/transform/generators/transform.ts index 0a9b34f21..c16a7f753 100644 --- a/packages/babel/src/transform/generators/transform.ts +++ b/packages/babel/src/transform/generators/transform.ts @@ -90,19 +90,15 @@ export const prepareCode = ( const { code, evalConfig, evaluator } = loadedAndParsed; - const preevalStageResult = eventEmitter.pair( - { - method: 'queue:transform:preeval', - }, - () => - runPreevalStage( - babel, - evalConfig, - pluginOptions, - code, - originalAst, - eventEmitter - ) + const preevalStageResult = eventEmitter.perf('transform:preeval', () => + runPreevalStage( + babel, + evalConfig, + pluginOptions, + code, + originalAst, + eventEmitter + ) ); const linariaMetadata = getLinariaMetadata(preevalStageResult.metadata); @@ -121,10 +117,8 @@ export const prepareCode = ( features: pluginOptions.features, }; - const [, transformedCode, imports] = eventEmitter.pair( - { - method: 'queue:transform:evaluator', - }, + const [, transformedCode, imports] = eventEmitter.perf( + 'transform:evaluator', () => evaluator( evalConfig, @@ -148,7 +142,10 @@ export function* internalTransform( const { only, loadedAndParsed, log } = this.entrypoint; if (loadedAndParsed.evaluator === 'ignored') { log('is ignored'); - return null; + return { + code: loadedAndParsed.code ?? '', + metadata: null, + }; } log('>> (%o)', only); @@ -169,7 +166,10 @@ export function* internalTransform( if (preparedCode === '') { log('is skipped'); - return null; + return { + code: loadedAndParsed.code ?? '', + metadata: null, + }; } if (imports !== null && imports.size > 0) { diff --git a/packages/babel/src/transform/generators/workflow.ts b/packages/babel/src/transform/generators/workflow.ts index d9824a41c..0176f61da 100644 --- a/packages/babel/src/transform/generators/workflow.ts +++ b/packages/babel/src/transform/generators/workflow.ts @@ -16,16 +16,6 @@ export function* workflow( const { cache, options } = this.services; const { entrypoint } = this; - if (entrypoint.supersededWith) { - entrypoint.log('entrypoint already superseded, rescheduling workflow'); - return yield* this.getNext( - 'workflow', - entrypoint.supersededWith!, - undefined, - null - ); - } - if (entrypoint.ignored) { return { code: entrypoint.loadedAndParsed.code ?? '', @@ -33,11 +23,15 @@ export function* workflow( }; } + entrypoint.assertNotSuperseded(); + + using abortSignal = null; const { code: originalCode = '' } = entrypoint.loadedAndParsed; // *** 1st stage *** - yield* this.getNext('processEntrypoint', entrypoint, undefined); + yield* this.getNext('processEntrypoint', entrypoint, undefined, abortSignal); + entrypoint.assertNotSuperseded(); // File is ignored or does not contain any tags. Return original code. if (!entrypoint.hasLinariaMetadata()) { @@ -58,7 +52,8 @@ export function* workflow( const evalStageResult = yield* this.getNext( 'evalFile', entrypoint, - undefined + undefined, + abortSignal ); if (evalStageResult === null) { @@ -72,9 +67,14 @@ export function* workflow( // *** 3rd stage *** - const collectStageResult = yield* this.getNext('collect', entrypoint, { - valueCache, - }); + const collectStageResult = yield* this.getNext( + 'collect', + entrypoint, + { + valueCache, + }, + abortSignal + ); if (!collectStageResult.metadata) { return { @@ -85,9 +85,14 @@ export function* workflow( // *** 4th stage - const extractStageResult = yield* this.getNext('extract', entrypoint, { - processors: collectStageResult.metadata.processors, - }); + const extractStageResult = yield* this.getNext( + 'extract', + entrypoint, + { + processors: collectStageResult.metadata.processors, + }, + abortSignal + ); return { ...extractStageResult, diff --git a/packages/babel/src/transform/helpers/loadLinariaOptions.ts b/packages/babel/src/transform/helpers/loadLinariaOptions.ts index d4a49d4c7..eeef1a27e 100644 --- a/packages/babel/src/transform/helpers/loadLinariaOptions.ts +++ b/packages/babel/src/transform/helpers/loadLinariaOptions.ts @@ -76,7 +76,7 @@ export default function loadLinariaOptions( } // If a file contains `export` or `import` keywords, we assume it's an ES-module - return /^(?:export|import)\b/m.test(code); + return /(?:^|\*\/|;)\s*(?:export|import)\s/m.test(code); }, action: require.resolve('@linaria/shaker'), }, diff --git a/packages/babel/src/transform/types.ts b/packages/babel/src/transform/types.ts index 315ba4049..9d37392cb 100644 --- a/packages/babel/src/transform/types.ts +++ b/packages/babel/src/transform/types.ts @@ -49,6 +49,7 @@ export type AnyIteratorResult = { export interface IBaseAction extends IBaseNode { abortSignal: AbortSignal | null; + createAbortSignal: () => AbortSignal & Disposable; data: TData; entrypoint: Entrypoint; getNext: GetNext; @@ -197,11 +198,7 @@ export interface IResolveImportsAction } export interface ITransformAction - extends IBaseAction< - ITransformAction, - ITransformFileResult | null, - undefined - > { + extends IBaseAction { type: 'transform'; } diff --git a/packages/babel/src/types.ts b/packages/babel/src/types.ts index fd3aeb157..881ac94dc 100644 --- a/packages/babel/src/types.ts +++ b/packages/babel/src/types.ts @@ -36,6 +36,7 @@ export type ParentEntrypoint = { log: Debugger; name: string; parent: ParentEntrypoint | null; + seqId: number; } | null; export type Dependencies = string[]; diff --git a/packages/babel/tsconfig.json b/packages/babel/tsconfig.json index 41670f7d3..ed58eb079 100644 --- a/packages/babel/tsconfig.json +++ b/packages/babel/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.json", - "compilerOptions": { "paths": {}, "rootDir": "src/", "types": ["jest", "node"] }, + "compilerOptions": { "paths": {}, "rootDir": "src/", "types": ["node"] }, "references": [ { "path": "../core" }, { "path": "../logger" }, diff --git a/packages/cli/src/linaria.ts b/packages/cli/src/linaria.ts index 87f090b7c..13e61b74a 100644 --- a/packages/cli/src/linaria.ts +++ b/packages/cli/src/linaria.ts @@ -12,7 +12,7 @@ import normalize from 'normalize-path'; import yargs from 'yargs'; import { TransformCacheCollection, transform } from '@linaria/babel-preset'; -import { asyncResolveFallback, createPerfMeter } from '@linaria/utils'; +import { asyncResolveFallback, createFileReporter } from '@linaria/utils'; const modulesOptions = [ 'commonjs', @@ -121,7 +121,7 @@ function resolveOutputFilename( } async function processFiles(files: (number | string)[], options: Options) { - const { emitter, onDone } = createPerfMeter(); + const { emitter, onDone } = createFileReporter(); const resolvedFiles = files.reduce( (acc, pattern) => [ diff --git a/packages/interop/package.json b/packages/interop/package.json index 83d6af525..e66137cdd 100644 --- a/packages/interop/package.json +++ b/packages/interop/package.json @@ -31,7 +31,7 @@ "@babel/types": "^7.22.15", "@types/babel__core": "^7.20.1", "@types/babel__traverse": "^7.20.1", - "dedent": "^0.7.0" + "dedent": "^1.5.1" }, "engines": { "node": ">=16.0.0" diff --git a/packages/server/package.json b/packages/server/package.json index 15be0c2b5..91e270f29 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -33,8 +33,7 @@ "postcss": "^8.3.11" }, "devDependencies": { - "@types/dedent": "^0.7.0", - "dedent": "^0.7.0", + "dedent": "^1.5.1", "prettier": "^3.0.3" }, "engines": { diff --git a/packages/shaker/package.json b/packages/shaker/package.json index 1c7a6f965..1cf6c542b 100644 --- a/packages/shaker/package.json +++ b/packages/shaker/package.json @@ -49,10 +49,9 @@ "@types/babel__core": "^7.20.1", "@types/babel__generator": "^7.6.4", "@types/babel__traverse": "^7.20.1", - "@types/dedent": "^0.7.0", "@types/jest": "^28.1.0", "@types/node": "^17.0.39", - "dedent": "^0.7.0", + "dedent": "^1.5.1", "jest": "^29.6.2", "ts-jest": "^29.1.1", "typescript": "^5.2.2" diff --git a/packages/testkit/package.json b/packages/testkit/package.json index a2e96f21b..0177ef272 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -31,7 +31,7 @@ "@linaria/tags": "workspace:^", "@swc/core": "^1.3.20", "debug": "^4.3.4", - "dedent": "^0.7.0", + "dedent": "^1.5.1", "esbuild": "^0.15.16", "strip-ansi": "^5.2.0", "typescript": "^5.2.2" @@ -52,7 +52,6 @@ "@types/babel__generator": "^7.6.4", "@types/babel__traverse": "^7.20.1", "@types/debug": "^4.1.8", - "@types/dedent": "^0.7.0", "@types/jest": "^28.1.0", "@types/node": "^17.0.39", "babel-plugin-istanbul": "^6.1.1", diff --git a/packages/testkit/src/__fixtures__/superseded/colors.js b/packages/testkit/src/__fixtures__/superseded/colors.js deleted file mode 100644 index cae847d2b..000000000 --- a/packages/testkit/src/__fixtures__/superseded/colors.js +++ /dev/null @@ -1 +0,0 @@ -export * from './red'; diff --git a/packages/testkit/src/__fixtures__/superseded/index.js b/packages/testkit/src/__fixtures__/superseded/index.js deleted file mode 100644 index f8d4aaacb..000000000 --- a/packages/testkit/src/__fixtures__/superseded/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import { styled } from '@linaria/react'; -import * as Colors from './colors'; - -export const color = 'red'; - -export const T1 = styled.h1` - color: ${Colors.RED}; -`; diff --git a/packages/testkit/src/__fixtures__/superseded/red.js b/packages/testkit/src/__fixtures__/superseded/red.js deleted file mode 100644 index 0d074d09f..000000000 --- a/packages/testkit/src/__fixtures__/superseded/red.js +++ /dev/null @@ -1,3 +0,0 @@ -import { color } from './index'; - -export const RED = color; diff --git a/packages/testkit/src/__snapshots__/babel.test.ts.snap b/packages/testkit/src/__snapshots__/babel.test.ts.snap index 876936e39..756234e0e 100644 --- a/packages/testkit/src/__snapshots__/babel.test.ts.snap +++ b/packages/testkit/src/__snapshots__/babel.test.ts.snap @@ -2556,28 +2556,6 @@ Dependencies: ./__fixtures__/module-reexport `; -exports[`strategy shaker should process superseded entrypoint 1`] = ` -"import { styled } from '@linaria/react'; -export const color = 'red'; -export const T1 = /*#__PURE__*/styled('h1')({ - name: "T1", - class: "T1_t1rerg8n", - propsAsIs: false -});" -`; - -exports[`strategy shaker should process superseded entrypoint 2`] = ` - -CSS: - -.T1_t1rerg8n { - color: red; -} - -Dependencies: ./colors - -`; - exports[`strategy shaker should process unary expressions in interpolation 1`] = ` "export const class1 = "class1_c13jq05"; export const class2 = "class2_c1vhermz";" @@ -3285,3 +3263,17 @@ CSS: Dependencies: NA `; + +exports[`strategy shaker uses values from json 1`] = `"export const eyeColorClass = "eyeColorClass_e13jq05";"`; + +exports[`strategy shaker uses values from json 2`] = ` + +CSS: + +.eyeColorClass_e13jq05 { + color: blue; +} + +Dependencies: ./__fixtures__/sample-data.json + +`; diff --git a/packages/testkit/src/babel.test.ts b/packages/testkit/src/babel.test.ts index 849beb252..c4d921fc7 100644 --- a/packages/testkit/src/babel.test.ts +++ b/packages/testkit/src/babel.test.ts @@ -15,8 +15,15 @@ import { Entrypoint, } from '@linaria/babel-preset'; import { linariaLogger } from '@linaria/logger'; -import type { Evaluator, StrictOptions, OnEvent } from '@linaria/utils'; +import type { + Evaluator, + StrictOptions, + OnEvent, + OnActionStartArgs, + OnActionFinishArgs, +} from '@linaria/utils'; import { EventEmitter } from '@linaria/utils'; +import type { EntrypointEvent } from '@linaria/utils/types/EventEmitter'; import serializer from './__utils__/linaria-snapshot-serializer'; @@ -119,7 +126,7 @@ async function transform( inputSourceMap: babelPartialConfig.inputSourceMap ?? undefined, pluginOptions: linariaConfig, }, - emitter: eventEmitter, + eventEmitter, }; const result = await linariaTransform(services, originalCode, asyncResolve); @@ -147,6 +154,44 @@ async function transformFile(filename: string, opts: Options) { describe('strategy shaker', () => { const evaluator = require('@linaria/shaker').default; + let onEvent: jest.Mock>; + let onAction: jest.Mock; + let onEntrypointEvent: jest.Mock< + void, + [idx: number, timestamp: number, event: EntrypointEvent] + >; + let emitter: EventEmitter; + + function hasNotBeenProcessed(filename: string) { + expect(onEntrypointEvent).toHaveBeenCalledWith( + expect.any(Number), + expect.any(Number), + expect.objectContaining({ + filename, + type: 'created', + }) + ); + + const entrypointId = onEntrypointEvent.mock.calls.find( + (call) => call[2].type === 'created' && call[2].filename === filename + )![0]; + + expect(onEntrypointEvent).not.toHaveBeenCalledWith( + entrypointId, + expect.any(Number), + expect.objectContaining({ + type: 'actionCreated', + actionType: 'explodeReexports', + }) + ); + } + + beforeEach(() => { + onEvent = jest.fn(); + onAction = jest.fn(); + onEntrypointEvent = jest.fn(); + emitter = new EventEmitter(onEvent, onAction, onEntrypointEvent); + }); it('transpiles styled template literal with object', async () => { const { code, metadata } = await transform( @@ -2416,6 +2461,23 @@ describe('strategy shaker', () => { expect(metadata).toMatchSnapshot(); }); + it('uses values from json', async () => { + const { code, metadata } = await transform( + dedent` + import { css } from '@linaria/core'; + import sample from './__fixtures__/sample-data.json'; + + export const eyeColorClass = css\` + color: ${'${sample.eye_color}'}; + \`; + `, + [evaluator] + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + it('evaluates complex styles with functions and nested selectors', async () => { const { code, metadata } = await transform( dedent` @@ -2554,8 +2616,6 @@ describe('strategy shaker', () => { }); it('should ignore unused wildcard reexports', async () => { - const onEvent = jest.fn>(); - const emitter = new EventEmitter(onEvent); const { code, metadata } = await transform( dedent` import { css } from "@linaria/core"; @@ -2573,11 +2633,15 @@ describe('strategy shaker', () => { expect(code).toMatchSnapshot(); expect(metadata).toMatchSnapshot(); + const reexports = resolve(__dirname, './__fixtures__/reexports.js'); const unusedFile = resolve(__dirname, './__fixtures__/bar.js'); - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ file: unusedFile, action: 'processImports' }), - 'single' + expect(onEntrypointEvent).toHaveBeenCalledWith( + expect.any(Number), + expect.any(Number), + expect.objectContaining({ filename: reexports, type: 'created' }) ); + + hasNotBeenProcessed(unusedFile); }); it('should not drop exported vars of renamed imports', async () => { @@ -2736,9 +2800,6 @@ describe('strategy shaker', () => { }); 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'; @@ -2758,20 +2819,9 @@ describe('strategy shaker', () => { 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' + hasNotBeenProcessed(resolve(__dirname, './__fixtures__/bar.js')); + hasNotBeenProcessed( + resolve(__dirname, './__fixtures__/re-exports/empty.js') ); }); @@ -3040,16 +3090,6 @@ describe('strategy shaker', () => { expect(metadata).toMatchSnapshot(); }); - it('should process superseded entrypoint', async () => { - const { code, metadata } = await transformFile( - resolve(__dirname, './__fixtures__/superseded/index.js'), - [evaluator] - ); - - expect(code).toMatchSnapshot(); - expect(metadata).toMatchSnapshot(); - }); - xit('should shake out side effect because its definition uses DOM API', async () => { const { code, metadata } = await transform( dedent` @@ -3202,9 +3242,6 @@ describe('strategy shaker', () => { 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'; @@ -3237,21 +3274,14 @@ describe('strategy shaker', () => { expect(metadata).toMatchSnapshot(); } - expect(onEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ - file: resolve(__dirname, './__fixtures__/re-exports/empty.js'), - action: 'processImports', - }), - 'single' + hasNotBeenProcessed( + resolve(__dirname, './__fixtures__/re-exports/empty.js') ); }); it('multiple parallel chains of reexports', async () => { const cache = new TransformCacheCollection(); - const onEvent = jest.fn>(); - const emitter = new EventEmitter(onEvent); - const tokens = ['foo', 'bar', 'bar1', 'bar2']; const results = await Promise.all( diff --git a/packages/testkit/src/module.test.ts b/packages/testkit/src/module.test.ts index ef60310ea..9cf36d9c1 100644 --- a/packages/testkit/src/module.test.ts +++ b/packages/testkit/src/module.test.ts @@ -7,6 +7,7 @@ import type { LoadAndParseFn, Services } from '@linaria/babel-preset'; import { DefaultModuleImplementation, Entrypoint, + isUnprocessedEntrypointError, Module, TransformCacheCollection, } from '@linaria/babel-preset'; @@ -63,6 +64,11 @@ const createEntrypoint = ( throw new Error('entrypoint was ignored'); } + entrypoint.setTransformResult({ + code, + metadata: null, + }); + return entrypoint; }; @@ -79,12 +85,46 @@ const create = (strings: TemplateStringsArray, ...expressions: unknown[]) => { }; }; +function safeEvaluate(m: Module): void { + try { + return m.evaluate(); + } catch (e) { + if (isUnprocessedEntrypointError(e)) { + e.entrypoint.setTransformResult({ + code: e.entrypoint.loadedAndParsed.code ?? '', + metadata: null, + }); + + return safeEvaluate(m); + } + + throw e; + } +} + +function safeRequire(m: Module, id: string): unknown { + try { + return m.require(id); + } catch (e) { + if (isUnprocessedEntrypointError(e)) { + e.entrypoint.setTransformResult({ + code: e.entrypoint.loadedAndParsed.code ?? '', + metadata: null, + }); + + return safeRequire(m, id); + } + + throw e; + } +} + it('creates module for JS files', () => { const { mod } = create` module.exports = () => 42; `; - mod.evaluate(); + safeEvaluate(mod); expect((mod.exports as any)()).toBe(42); expect(mod.id).toBe(filename); @@ -98,7 +138,7 @@ it('requires .js files', () => { module.exports = 'The answer is ' + answer; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe('The answer is 42'); }); @@ -109,7 +149,7 @@ it('requires .cjs files', () => { module.exports = 'The answer is ' + answer; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe('The answer is 42'); }); @@ -120,7 +160,7 @@ it('requires .json files', () => { module.exports = 'Our saviour, ' + data.name; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe('Our saviour, Luke Skywalker'); }); @@ -130,10 +170,10 @@ it('returns module from the cache', () => { const id = './sample-data.json'; - expect(mod.require(id)).toBe(mod.require(id)); + expect(safeRequire(mod, id)).toBe(safeRequire(mod, id)); - const res1 = new Module(entrypoint, cache).require(id); - const res2 = new Module(entrypoint, cache).require(id); + const res1 = safeRequire(new Module(entrypoint, cache), id); + const res2 = safeRequire(new Module(entrypoint, cache), id); expect(res1).toBe(res2); }); @@ -146,10 +186,10 @@ it('should use cached version from the codeCache', () => { `; const resolved = require.resolve('./__fixtures__/objectExport.js'); - entrypoint.addDependency('./objectExport', { + entrypoint.addDependency({ only: ['margin'], resolved, - source: 'objectExport', + source: './objectExport', }); entrypoint.createChild( @@ -160,7 +200,7 @@ it('should use cached version from the codeCache', () => { ` ); - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe('Imported value is 1'); }); @@ -185,7 +225,7 @@ it('should reread module from disk when it is in codeCache but not in resolveCac ` ); - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe('Imported value is 5'); }); @@ -194,14 +234,14 @@ it('clears modules from the cache', () => { const id = './sample-data.json'; const { entrypoint, mod, cache } = create``; - const result = mod.require(id); + const result = safeRequire(mod, id); - expect(new Module(entrypoint, cache).require(id)).toBe(result); + expect(safeRequire(new Module(entrypoint, cache), id)).toBe(result); const dep = new Module(entrypoint, cache).resolve(id); cache.invalidateForFile(dep); - expect(new Module(entrypoint, cache).require(id)).not.toBe(result); + expect(safeRequire(new Module(entrypoint, cache), id)).not.toBe(result); }); it('exports the path for non JS/JSON files', () => { @@ -261,7 +301,7 @@ it('has access to NODE_ENV', () => { module.exports = process.env.NODE_ENV; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe(process.env.NODE_ENV); }); @@ -271,7 +311,7 @@ it('has require.resolve available', () => { module.exports = require.resolve('./sample-script'); `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe( path.resolve(path.dirname(mod.filename), 'sample-script.js') @@ -298,7 +338,7 @@ it('changes resolve behaviour on overriding _resolveFilename', () => { ]; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toEqual(['bar', 'test']); expect(resolveFilename).toHaveBeenCalledTimes(2); @@ -319,18 +359,18 @@ it('should resolve from the cache', () => { ]; `; - entrypoint.addDependency('foo', { + entrypoint.addDependency({ only: ['*'], resolved: 'resolved foo', source: 'foo', }); - entrypoint.addDependency('test', { + entrypoint.addDependency({ only: ['*'], resolved: 'resolved test', source: 'test', }); - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toEqual(['resolved foo', 'resolved test']); expect(resolveFilename).toHaveBeenCalledTimes(0); @@ -344,7 +384,7 @@ it('correctly processes export declarations in strict mode', () => { exports = module.exports = () => 42 `; - mod.evaluate(); + safeEvaluate(mod); expect((mod.exports as any)()).toBe(42); expect(mod.id).toBe(filename); @@ -358,7 +398,7 @@ it('export * compiled by typescript to commonjs works', () => { module.exports = foo; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe('foo'); }); @@ -394,7 +434,7 @@ describe('definable globals', () => { module.exports = __filename; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe(mod.filename); }); @@ -404,7 +444,7 @@ describe('definable globals', () => { module.exports = __dirname; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toBe(path.dirname(mod.filename)); }); @@ -420,7 +460,7 @@ describe('DOM', () => { }; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toEqual({ document: 'object', @@ -456,7 +496,7 @@ describe('DOM', () => { }; `; - mod.evaluate(); + safeEvaluate(mod); expect(mod.exports).toEqual({ html: '
', diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js new file mode 100644 index 000000000..8f000f232 --- /dev/null +++ b/packages/utils/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.spec.json', + }, + ], + }, +}; diff --git a/packages/utils/package.json b/packages/utils/package.json index 7c766382a..aa215326c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,6 +27,7 @@ "build:declarations": "tsc --emitDeclarationOnly --outDir types", "build:esm": "babel src --out-dir esm --extensions '.js,.jsx,.ts,.tsx' --source-maps --delete-dir-on-start", "build:lib": "cross-env NODE_ENV=legacy babel src --out-dir lib --extensions '.js,.jsx,.ts,.tsx' --source-maps --delete-dir-on-start", + "test": "jest --config ./jest.config.js --rootDir src", "typecheck": "tsc --noEmit --composite false", "watch": "pnpm build:lib --watch & pnpm build:esm --watch & pnpm build:declarations --watch" }, @@ -47,7 +48,12 @@ "@types/babel__generator": "^7.6.4", "@types/babel__template": "^7.4.1", "@types/babel__traverse": "^7.20.1", - "@types/node": "^17.0.39" + "@types/jest": "^28.1.0", + "@types/node": "^17.0.39", + "dedent": "^1.5.1", + "jest": "^29.6.2", + "ts-jest": "^29.1.1", + "typescript": "^5.2.2" }, "engines": { "node": ">=16.0.0" diff --git a/packages/utils/src/EventEmitter.ts b/packages/utils/src/EventEmitter.ts index d168b6cf9..feccc57b5 100644 --- a/packages/utils/src/EventEmitter.ts +++ b/packages/utils/src/EventEmitter.ts @@ -4,33 +4,144 @@ export type OnEvent = ( event?: unknown ) => void; +export interface IActionCreated { + actionIdx: string; + actionType: string; + type: 'actionCreated'; +} + +export interface ICreatedEvent { + class: string; + evaluatedOnly: string[]; + filename: string; + generation: number; + idx: string; + isExportsInherited: boolean; + only: string[]; + parentId: number | null; + type: 'created'; +} + +export interface ISupersededEvent { + type: 'superseded'; + with: number; +} + +export interface ISetTransformResultEvent { + isNull: boolean; + type: 'setTransformResult'; +} + +export type EntrypointEvent = + | IActionCreated + | ICreatedEvent + | ISupersededEvent + | ISetTransformResultEvent; + +export type OnEntrypointEvent = ( + idx: number, + timestamp: number, + event: EntrypointEvent +) => void; + +export type OnActionStartArgs = [ + phase: 'start', + timestamp: number, + type: string, + idx: string, + entrypointRef: string, +]; + +export type OnActionFinishArgs = [ + phase: 'finish' | 'fail', + timestamp: number, + id: number, + isAsync: boolean, + error?: unknown, +]; + +export const isOnActionStartArgs = ( + args: OnActionStartArgs | OnActionFinishArgs +): args is OnActionStartArgs => { + return args[0] === 'start'; +}; + +export const isOnActionFinishArgs = ( + args: OnActionStartArgs | OnActionFinishArgs +): args is OnActionFinishArgs => { + return args[0] === 'finish' || args[0] === 'fail'; +}; + +export interface OnAction { + (...args: OnActionStartArgs): number; + (...args: OnActionFinishArgs): void; +} + export class EventEmitter { - static dummy = new EventEmitter(() => {}); + static dummy = new EventEmitter( + () => {}, + () => 0, + () => {} + ); - constructor(protected onEvent: OnEvent) {} + constructor( + protected onEvent: OnEvent, + protected onAction: OnAction, + protected onEntrypointEvent: OnEntrypointEvent + ) {} - public pair(labels: Record): () => void; - public pair(labels: Record, fn: () => TRes): TRes; - public pair(labels: Record, fn?: () => TRes) { - this.onEvent(labels, 'start'); + public action( + actonType: string, + idx: string, + entrypointRef: string, + fn: () => TRes + ) { + const id = this.onAction( + 'start', + performance.now(), + actonType, + idx, + entrypointRef + ); - if (fn) { + try { const result = fn(); if (result instanceof Promise) { result.then( - () => this.onEvent(labels, 'finish'), - () => this.onEvent(labels, 'finish') + () => this.onAction('finish', performance.now(), id, true), + (e) => this.onAction('fail', performance.now(), id, true, e) ); } else { - this.onEvent(labels, 'finish'); + this.onAction('finish', performance.now(), id, false); } return result; + } catch (e) { + this.onAction('fail', performance.now(), id, false, e); + throw e; } + } - return () => { + public entrypointEvent(sequenceId: number, event: EntrypointEvent) { + this.onEntrypointEvent(sequenceId, performance.now(), event); + } + + public perf(method: string, fn: () => TRes): TRes { + const labels = { method }; + + this.onEvent(labels, 'start'); + + 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 single(labels: Record) { diff --git a/packages/utils/src/__tests__/__snapshots__/removeDangerousCode.test.ts.snap b/packages/utils/src/__tests__/__snapshots__/removeDangerousCode.test.ts.snap new file mode 100644 index 000000000..0a25ab8d0 --- /dev/null +++ b/packages/utils/src/__tests__/__snapshots__/removeDangerousCode.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`removeDangerousCode should remove \`window\` but keep \`setVersion\` function untouched 1`] = ` +"let _win = undefined; +export function setVersion(packageName, packageVersion) { + if (typeof _win !== 'undefined') { + return null; + } +}" +`; + +exports[`removeDangerousCode should replace body of react component with null 1`] = ` +"export var Popup = /*#__PURE__*/function () { + var Popup = function Popup() { + return null; + }; + Popup.displayName = 'Popup'; + return Popup; +}();" +`; diff --git a/packages/testkit/src/utils/__snapshots__/removeWithRelated.test.ts.snap b/packages/utils/src/__tests__/__snapshots__/removeWithRelated.test.ts.snap similarity index 100% rename from packages/testkit/src/utils/__snapshots__/removeWithRelated.test.ts.snap rename to packages/utils/src/__tests__/__snapshots__/removeWithRelated.test.ts.snap diff --git a/packages/utils/src/__tests__/removeDangerousCode.test.ts b/packages/utils/src/__tests__/removeDangerousCode.test.ts new file mode 100644 index 000000000..a56b4d0af --- /dev/null +++ b/packages/utils/src/__tests__/removeDangerousCode.test.ts @@ -0,0 +1,67 @@ +import { parseSync } from '@babel/core'; +import generate from '@babel/generator'; +import traverse from '@babel/traverse'; +import dedent from 'dedent'; + +import { removeDangerousCode } from '../removeDangerousCode'; + +const run = (code: TemplateStringsArray) => { + const ast = parseSync(dedent(code), { + filename: 'test.tsx', + presets: ['@babel/preset-typescript', '@babel/preset-react'], + }); + + if (!ast) { + throw new Error('Failed to parse'); + } + + traverse(ast, { + Program(path) { + removeDangerousCode(path); + }, + }); + + return generate(ast).code; +}; + +describe('removeDangerousCode', () => { + it('should be defined', () => { + expect(removeDangerousCode).toBeDefined(); + }); + + it('should remove `window` but keep `setVersion` function untouched', () => { + const result = run` + let _win = undefined; + + try { + _win = window; + } catch (e) { + /* no-op */ + } + export function setVersion(packageName, packageVersion) { + if (typeof _win !== 'undefined') { + return null; + } + } + `; + + expect(result).toMatchSnapshot(); + }); + + it('should replace body of react component with null', () => { + const result = run` + export var Popup = /*#__PURE__*/function () { + var Popup = function Popup() { + var name = Popup.displayName; + return
{name}
; + }; + + Popup.displayName = 'Popup'; + + return Popup; + }(); + `; + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/testkit/src/utils/removeWithRelated.test.ts b/packages/utils/src/__tests__/removeWithRelated.test.ts similarity index 93% rename from packages/testkit/src/utils/removeWithRelated.test.ts rename to packages/utils/src/__tests__/removeWithRelated.test.ts index 40a120141..10bb5b473 100644 --- a/packages/testkit/src/utils/removeWithRelated.test.ts +++ b/packages/utils/src/__tests__/removeWithRelated.test.ts @@ -4,10 +4,17 @@ import { join } from 'path'; import * as babel from '@babel/core'; import type { NodePath } from '@babel/core'; import generator from '@babel/generator'; +import type { File as FileNode } from '@babel/types'; import dedent from 'dedent'; -import type { MissedBabelCoreTypes } from '@linaria/babel-preset'; -import { removeWithRelated } from '@linaria/utils'; +import { removeWithRelated } from '../scopeHelpers'; + +type MissedBabelCoreTypes = { + File: new ( + options: { filename: string }, + file: { ast: FileNode; code: string } + ) => { path: NodePath }; +}; const { File } = babel as typeof babel & MissedBabelCoreTypes; diff --git a/packages/utils/src/collectTemplateDependencies.ts b/packages/utils/src/collectTemplateDependencies.ts index 91761eb8e..dbda9966c 100644 --- a/packages/utils/src/collectTemplateDependencies.ts +++ b/packages/utils/src/collectTemplateDependencies.ts @@ -81,14 +81,14 @@ function hoistVariableDeclarator(ex: NodePath) { return; } - const referencedIdentifiers = findIdentifiers([ex], 'referenced'); + const referencedIdentifiers = findIdentifiers([ex], 'reference'); referencedIdentifiers.forEach((identifier) => { if (identifier.isIdentifier()) { hoistIdentifier(identifier); } }); - const bindingIdentifiers = findIdentifiers([ex], 'binding'); + const bindingIdentifiers = findIdentifiers([ex], 'declaration'); bindingIdentifiers.forEach((path) => { const newName = getUidInRootScope(path); @@ -194,7 +194,7 @@ export function extractExpression( // we need to hoist all its referenced identifiers // Collect all referenced identifiers - findIdentifiers([ex], 'referenced').forEach((id) => { + findIdentifiers([ex], 'reference').forEach((id) => { if (!id.isIdentifier()) return; // Try to evaluate and inline them… diff --git a/packages/utils/src/debug/fileReporter.ts b/packages/utils/src/debug/fileReporter.ts new file mode 100644 index 000000000..3bb6a9572 --- /dev/null +++ b/packages/utils/src/debug/fileReporter.ts @@ -0,0 +1,229 @@ +/* eslint-disable no-console */ +import { createWriteStream, existsSync, mkdirSync } from 'fs'; +import path from 'path'; + +import type { + OnAction, + OnEvent, + OnActionFinishArgs, + OnActionStartArgs, + OnEntrypointEvent, +} from '../EventEmitter'; +import { EventEmitter, isOnActionStartArgs } from '../EventEmitter'; + +type Timings = Map>; + +export interface IFileReporterOptions { + dir?: string; + print?: boolean; +} + +export interface IProcessedEvent { + file: string; + fileIdx: string; + imports: { from: string; what: string[] }[]; + only: string[]; + type: 'dependency'; +} + +export interface IQueueActionEvent { + action: string; + args?: string[]; + datetime: Date; + file: string; + queueIdx: string; + type: 'queue-action'; +} + +const workingDir = process.cwd(); + +function replacer(_key: string, value: unknown): unknown { + if (typeof value === 'string' && path.isAbsolute(value)) { + return path.relative(workingDir, value); + } + + if (value instanceof Map) { + return Array.from(value.entries()).reduce((obj, [k, v]) => { + const key = replacer(k, k) as string; + return { + ...obj, + [key]: replacer(key, v), + }; + }, {}); + } + + return value; +} + +function printTimings(timings: Timings, startedAt: number, sourceRoot: string) { + if (timings.size === 0) { + return; + } + + console.log(`\nTimings:`); + console.log(` Total: ${(performance.now() - startedAt).toFixed()}ms`); + + Array.from(timings.entries()).forEach(([label, byLabel]) => { + console.log(`\n By ${label}:`); + + const array = Array.from(byLabel.entries()); + // array.sort(([, a], [, b]) => b - a); + array + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([value, time]) => { + const name = value.startsWith(sourceRoot) + ? path.relative(sourceRoot, value) + : value; + console.log(` ${name}: ${time}ms`); + }); + }); +} + +const writeJSONl = (stream: NodeJS.WritableStream, data: unknown) => { + stream.write(`${JSON.stringify(data, replacer)}\n`); +}; + +export const createFileReporter = ( + options: IFileReporterOptions | false = false +) => { + if (!options || !options.dir) { + return { + emitter: EventEmitter.dummy, + onDone: () => {}, + }; + } + + const reportFolder = existsSync(options.dir) + ? options.dir + : mkdirSync(options.dir, { + recursive: true, + }); + + if (!reportFolder) { + throw new Error(`Could not create directory ${options.dir}`); + } + + const actionStream = createWriteStream( + path.join(options.dir, 'actions.jsonl') + ); + + const dependenciesStream = createWriteStream( + path.join(options.dir, 'dependencies.jsonl') + ); + + const entrypointStream = createWriteStream( + path.join(options.dir, 'entrypoint.jsonl') + ); + + const startedAt = performance.now(); + const timings: Timings = new Map(); + const addTiming = (label: string, key: string, value: number) => { + if (!timings.has(label)) { + timings.set(label, new Map()); + } + + const forLabel = timings.get(label)!; + forLabel.set(key, Math.round((forLabel.get(key) || 0) + value)); + }; + + const processDependencyEvent = ({ + file, + only, + imports, + fileIdx, + }: IProcessedEvent) => { + writeJSONl(dependenciesStream, { + file, + only, + imports, + fileIdx, + }); + }; + + const processSingleEvent = ( + meta: Record | IProcessedEvent | IQueueActionEvent + ) => { + if (meta.type === 'dependency') { + processDependencyEvent(meta as IProcessedEvent); + } + }; + + const startTimes = new Map(); + + const onEvent: OnEvent = (meta, type) => { + if (type === 'single') { + processSingleEvent(meta); + return; + } + + if (type === 'start') { + Object.entries(meta).forEach(([label, value]) => { + startTimes.set(`${label}\0${value}`, performance.now()); + }); + } else { + Object.entries(meta).forEach(([label, value]) => { + const startTime = startTimes.get(`${label}\0${value}`); + if (startTime) { + addTiming(label, String(value), performance.now() - startTime); + } + }); + } + }; + + let actionId = 0; + const onAction: OnAction = ( + ...args: OnActionStartArgs | OnActionFinishArgs + ) => { + if (isOnActionStartArgs(args)) { + const [, timestamp, type, idx, entrypointRef] = args; + writeJSONl(actionStream, { + actionId, + entrypointRef, + idx, + startedAt: timestamp, + type, + }); + + // eslint-disable-next-line no-plusplus + return actionId++; + } + + const [result, timestamp, id, isAsync, error] = args; + writeJSONl(actionStream, { + actionId: id, + error, + finishedAt: timestamp, + isAsync, + result: `${result}ed`, + }); + + return id; + }; + + const onEntrypointEvent: OnEntrypointEvent = ( + emitterId, + timestamp, + event + ) => { + entrypointStream.write( + `${JSON.stringify([emitterId, timestamp, event])}\n` + ); + }; + + const emitter = new EventEmitter(onEvent, onAction, onEntrypointEvent); + + return { + emitter, + onDone: (sourceRoot: string) => { + if (options.print) { + printTimings(timings, startedAt, sourceRoot); + + console.log('\nMemory usage:', process.memoryUsage()); + } + + actionStream.end(); + dependenciesStream.end(); + timings.clear(); + }, + }; +}; diff --git a/packages/utils/src/debug/perfMetter.ts b/packages/utils/src/debug/perfMetter.ts index b7c26b731..3cddcea65 100644 --- a/packages/utils/src/debug/perfMetter.ts +++ b/packages/utils/src/debug/perfMetter.ts @@ -1,7 +1,13 @@ /* eslint-disable no-console */ import path from 'path'; -import { EventEmitter } from '../EventEmitter'; +import type { + OnAction, + OnEvent, + OnActionFinishArgs, + OnActionStartArgs, +} from '../EventEmitter'; +import { EventEmitter, isOnActionStartArgs } from '../EventEmitter'; type Timings = Map>; @@ -34,7 +40,23 @@ export interface IQueueActionEvent { type: 'queue-action'; } -const formatTime = (date: Date) => { +interface IAction { + entrypointRef: string; + error?: unknown; + finishedAt?: number; + idx: string; + isAsync?: boolean; + result?: 'finished' | 'failed'; + startedAt: number; + type: string; +} + +const formatTime = (timestamps: number | undefined) => { + if (!timestamps) { + return 'unfinished'; + } + + const date = new Date(performance.timeOrigin + timestamps); return `${date.toLocaleTimeString()}.${date .getMilliseconds() .toString() @@ -85,6 +107,46 @@ function printTimings(timings: Timings, startedAt: number, sourceRoot: string) { }); } +function printActions(actions: IAction[]) { + const actionsIdx = new Set(); + const actionsByType = new Map>(); + const actionsByEntrypoint = new Map>(); + + const getIdx = (action: IAction) => action.idx.split(':')[0]; + + actions.forEach((action) => { + actionsIdx.add(getIdx(action)); + + if (!actionsByType.has(action.type)) { + actionsByType.set(action.type, new Set()); + } + + actionsByType.get(action.type)!.add(getIdx(action)); + + if (!actionsByEntrypoint.has(action.entrypointRef)) { + actionsByEntrypoint.set(action.entrypointRef, new Set()); + } + + actionsByEntrypoint.get(action.entrypointRef)!.add(getIdx(action)); + }); + + console.log('\nActions:'); + console.log(` Total: ${actionsIdx.size}`); + console.log(` By type:`); + Array.from(actionsByType.entries()) + .sort(([, a], [, b]) => b.size - a.size) + .forEach(([type, set]) => { + console.log(` ${type}: ${set.size}`); + }); + console.log(` By entrypoint (top 10):`); + Array.from(actionsByEntrypoint.entries()) + .sort(([, a], [, b]) => b.size - a.size) + .slice(0, 10) + .forEach(([entrypoint, set]) => { + console.log(` ${entrypoint}: ${set.size}`); + }); +} + export const createPerfMeter = ( options: IPerfMeterOptions | boolean = true ) => { @@ -128,41 +190,17 @@ export const createPerfMeter = ( processed.imports = imports; }; - const queueActions = new Map(); - const processQueueAction = ({ - file, - action, - args, - queueIdx, - datetime, - }: IQueueActionEvent) => { - if (!queueActions.has(file)) { - queueActions.set(file, []); - } - - const stringifiedArgs = - args?.map((arg) => JSON.stringify(arg)).join(', ') ?? ''; - queueActions - .get(file)! - .push( - `${queueIdx}:${action}(${stringifiedArgs})@${formatTime(datetime)}` - ); - }; - const processSingleEvent = ( meta: Record | IProcessedEvent | IQueueActionEvent ) => { if (meta.type === 'dependency') { processDependencyEvent(meta as IProcessedEvent); } - - if (meta.type === 'queue-action') { - processQueueAction(meta as IQueueActionEvent); - } }; const startTimes = new Map(); - const emitter = new EventEmitter((meta, type) => { + + const onEvent: OnEvent = (meta, type) => { if (type === 'single') { processSingleEvent(meta); return; @@ -180,13 +218,57 @@ export const createPerfMeter = ( } }); } - }); + }; + + const actions: IAction[] = []; + + const onAction: OnAction = ( + ...args: OnActionStartArgs | OnActionFinishArgs + ) => { + if (isOnActionStartArgs(args)) { + const [, timestamp, type, idx, entrypointRef] = args; + const id = actions.length; + actions.push({ + entrypointRef, + idx, + startedAt: timestamp, + type, + }); + + return id; + } + + const [result, timestamp, id, isAsync, error] = args; + actions[id].error = error; + actions[id].finishedAt = timestamp; + actions[id].isAsync = isAsync; + actions[id].result = `${result}ed`; + + addTiming( + 'actions', + `${isAsync ? 'async' : 'sync'} ${actions[id].type}`, + timestamp - actions[id].startedAt + ); + + return id; + }; + + const emitter = new EventEmitter(onEvent, onAction, () => {}); return { emitter, onDone: (sourceRoot: string) => { if (options === true || options.print) { printTimings(timings, startedAt, sourceRoot); + + console.log( + '\nNumber of processed dependencies:', + processedDependencies.size + ); + + printActions(actions); + + console.log('\nMemory usage:', process.memoryUsage()); } if (options !== true && options.filename) { @@ -196,7 +278,13 @@ export const createPerfMeter = ( JSON.stringify( { processedDependencies, - queueActions, + actions: actions.map(({ finishedAt, ...action }) => ({ + ...action, + duration: finishedAt + ? finishedAt - action.startedAt + : 'unfinished', + startedAt: formatTime(action.startedAt), + })), timings, }, replacer, @@ -205,11 +293,9 @@ export const createPerfMeter = ( ); } + actions.length = 0; timings.clear(); processedDependencies.clear(); - queueActions.clear(); - - console.log(process.memoryUsage()); }, }; }; diff --git a/packages/utils/src/findIdentifiers.ts b/packages/utils/src/findIdentifiers.ts index 6eabb1a19..5b36b351e 100644 --- a/packages/utils/src/findIdentifiers.ts +++ b/packages/utils/src/findIdentifiers.ts @@ -8,7 +8,7 @@ import type { import { getScope } from './getScope'; -type FindType = 'binding' | 'both' | 'referenced'; +type FindType = 'any' | 'binding' | 'declaration' | 'reference'; function isInUnary( path: T @@ -34,10 +34,16 @@ function isReferencedIdentifier( } // For some reasons, `isBindingIdentifier` returns true for identifiers inside unary expressions. -const checkers: Record boolean> = { +const checkers: Record< + FindType, + (ex: NodePath) => boolean +> = { + any: (ex) => isBindingIdentifier(ex) || isReferencedIdentifier(ex), binding: (ex) => isBindingIdentifier(ex), - both: (ex) => isBindingIdentifier(ex) || isReferencedIdentifier(ex), - referenced: (ex) => isReferencedIdentifier(ex), + declaration: (ex) => + isBindingIdentifier(ex) && + ex.scope.getBinding(ex.node.name)?.identifier === ex.node, + reference: (ex) => isReferencedIdentifier(ex), }; export function nonType(path: NodePath): boolean { @@ -53,7 +59,7 @@ export function nonType(path: NodePath): boolean { export default function findIdentifiers( expressions: NodePath[], - type: FindType = 'referenced' + type: FindType = 'reference' ): NodePath[] { const identifiers: NodePath[] = []; @@ -70,7 +76,7 @@ export default function findIdentifiers( return; } - if (type === 'referenced' && ex.isAncestor(binding.path)) { + if (type === 'reference' && ex.isAncestor(binding.path)) { // This identifier is declared inside the expression. We don't need it. return; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c569b4a0d..02b74c8ee 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -16,8 +16,13 @@ export { extractExpression, } from './collectTemplateDependencies'; export { createId } from './createId'; +export { createFileReporter } from './debug/fileReporter'; export { createPerfMeter } from './debug/perfMetter'; -export { EventEmitter } from './EventEmitter'; +export { + EventEmitter, + OnActionFinishArgs, + OnActionStartArgs, +} from './EventEmitter'; export { default as findIdentifiers, nonType } from './findIdentifiers'; export { findPackageJSON } from './findPackageJSON'; export { hasEvaluatorMetadata } from './hasEvaluatorMetadata'; @@ -61,6 +66,7 @@ export type { ISideEffectImport, IState, } from './collectExportsAndImports'; +export type { IFileReporterOptions } from './debug/fileReporter'; export type { IPerfMeterOptions } from './debug/perfMetter'; export type { OnEvent } from './EventEmitter'; export type { IVariableContext } from './IVariableContext'; diff --git a/packages/utils/src/scopeHelpers.ts b/packages/utils/src/scopeHelpers.ts index 57311070c..e6b1ff71a 100644 --- a/packages/utils/src/scopeHelpers.ts +++ b/packages/utils/src/scopeHelpers.ts @@ -61,7 +61,24 @@ export function reference( binding.references = binding.referencePaths.length; } -function isReferenced({ kind, referenced, referencePaths }: Binding) { +function isReferenced(binding: Binding): boolean { + const { kind, referenced, referencePaths, path } = binding; + + if ( + path.isFunctionExpression() && + path.key === 'init' && + path.parentPath.isVariableDeclarator() + ) { + // It is a function expression in a variable declarator + const id = path.parentPath.get('id'); + if (id.isIdentifier()) { + const idBinding = getBinding(id); + return idBinding ? isReferenced(idBinding) : true; + } + + return true; + } + if (!referenced) { return false; } @@ -466,12 +483,12 @@ function removeWithRelated(paths: NodePath[]) { const affectedPaths = actions.map(getPathFromAction); - let referencedIdentifiers = findIdentifiers(affectedPaths, 'referenced'); + let referencedIdentifiers = findIdentifiers(affectedPaths, 'reference'); referencedIdentifiers.sort( (a, b) => a.node?.name.localeCompare(b.node?.name) ); - const referencesOfBinding = findIdentifiers(affectedPaths, 'binding') + const referencesOfBinding = findIdentifiers(affectedPaths, 'declaration') .map((i) => (i.node && getScope(i).getBinding(i.node.name)) ?? null) .filter(isNotNull) .reduce( diff --git a/packages/utils/tsconfig.spec.json b/packages/utils/tsconfig.spec.json new file mode 100644 index 000000000..3a33cf882 --- /dev/null +++ b/packages/utils/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index deb3980cd..c5438a96e 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -17,11 +17,11 @@ import { } from '@linaria/babel-preset'; import type { PluginOptions, Preprocessor } from '@linaria/babel-preset'; import { linariaLogger } from '@linaria/logger'; -import type { IPerfMeterOptions } from '@linaria/utils'; -import { createPerfMeter, getFileIdx, syncResolve } from '@linaria/utils'; +import type { IFileReporterOptions } from '@linaria/utils'; +import { createFileReporter, getFileIdx, syncResolve } from '@linaria/utils'; type VitePluginOptions = { - debug?: IPerfMeterOptions | false | null | undefined; + debug?: IFileReporterOptions | false | null | undefined; exclude?: FilterPattern; include?: FilterPattern; preprocessor?: Preprocessor; @@ -44,7 +44,7 @@ export default function linaria({ let config: ResolvedConfig; let devServer: ViteDevServer; - const { emitter, onDone } = createPerfMeter(debug ?? false); + const { emitter, onDone } = createFileReporter(debug ?? false); // const targets: { dependencies: string[]; id: string }[] = []; diff --git a/packages/webpack5-loader/src/LinariaDebugPlugin.ts b/packages/webpack5-loader/src/LinariaDebugPlugin.ts index c80c09792..e797d1790 100644 --- a/packages/webpack5-loader/src/LinariaDebugPlugin.ts +++ b/packages/webpack5-loader/src/LinariaDebugPlugin.ts @@ -1,7 +1,7 @@ import type { Compiler } from 'webpack'; -import type { EventEmitter, IPerfMeterOptions } from '@linaria/utils'; -import { createPerfMeter } from '@linaria/utils'; +import type { EventEmitter, IFileReporterOptions } from '@linaria/utils'; +import { createFileReporter } from '@linaria/utils'; export const sharedState: { emitter?: EventEmitter; @@ -10,8 +10,8 @@ export const sharedState: { export class LinariaDebugPlugin { private readonly onDone: (root: string) => void; - constructor(options?: IPerfMeterOptions) { - const { emitter, onDone } = createPerfMeter(options ?? true); + constructor(options?: IFileReporterOptions) { + const { emitter, onDone } = createFileReporter(options ?? false); sharedState.emitter = emitter; this.onDone = onDone; } diff --git a/packages/webpack5-loader/src/index.ts b/packages/webpack5-loader/src/index.ts index ebac9fd24..6d4af0391 100644 --- a/packages/webpack5-loader/src/index.ts +++ b/packages/webpack5-loader/src/index.ts @@ -97,56 +97,58 @@ const webpack5Loader: Loader = function webpack5LoaderPlugin( eventEmitter: sharedState.emitter, }; - transform(transformServices, content.toString(), asyncResolve).then( - async (result: Result) => { - if (result.cssText) { - let { cssText } = result; - - if (sourceMap) { - cssText += `/*# sourceMappingURL=data:application/json;base64,${Buffer.from( - result.cssSourceMapText || '' - ).toString('base64')}*/`; - } - - await Promise.all( - result.dependencies?.map((dep) => - asyncResolve(dep, this.resourcePath) - ) ?? [] - ); - - try { - const cacheInstance = await getCacheInstance(cacheProvider); - - await cacheInstance.set(this.resourcePath, cssText); - - await cacheInstance.setDependencies?.( - this.resourcePath, - this.getDependencies() + transform(transformServices, content.toString(), asyncResolve) + .then( + async (result: Result) => { + if (result.cssText) { + let { cssText } = result; + + if (sourceMap) { + cssText += `/*# sourceMappingURL=data:application/json;base64,${Buffer.from( + result.cssSourceMapText || '' + ).toString('base64')}*/`; + } + + await Promise.all( + result.dependencies?.map((dep) => + asyncResolve(dep, this.resourcePath) + ) ?? [] ); - const request = `${outputFileName}!=!${outputCssLoader}?cacheProvider=${encodeURIComponent( - typeof cacheProvider === 'string' ? cacheProvider : '' - )}!${this.resourcePath}`; - const stringifiedRequest = JSON.stringify( - this.utils.contextify(this.context || this.rootContext, request) - ); - - this.callback( - null, - `${result.code}\n\nrequire(${stringifiedRequest});`, - result.sourceMap ?? undefined - ); - } catch (err) { - this.callback(err as Error); + try { + const cacheInstance = await getCacheInstance(cacheProvider); + + await cacheInstance.set(this.resourcePath, cssText); + + await cacheInstance.setDependencies?.( + this.resourcePath, + this.getDependencies() + ); + + const request = `${outputFileName}!=!${outputCssLoader}?cacheProvider=${encodeURIComponent( + typeof cacheProvider === 'string' ? cacheProvider : '' + )}!${this.resourcePath}`; + const stringifiedRequest = JSON.stringify( + this.utils.contextify(this.context || this.rootContext, request) + ); + + this.callback( + null, + `${result.code}\n\nrequire(${stringifiedRequest});`, + result.sourceMap ?? undefined + ); + } catch (err) { + this.callback(err as Error); + } + + return; } - return; - } - - this.callback(null, result.code, result.sourceMap ?? undefined); - }, - (err: Error) => this.callback(err) - ); + this.callback(null, result.code, result.sourceMap ?? undefined); + }, + (err: Error) => this.callback(err) + ) + .catch((err: Error) => this.callback(err)); }; export default webpack5Loader; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecbc6f43a..416e3e224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,9 +408,6 @@ importers: '@types/babel__traverse': specifier: ^7.20.1 version: 7.20.1 - '@types/dedent': - specifier: ^0.7.0 - version: 0.7.0 '@types/jest': specifier: ^28.1.0 version: 28.1.0 @@ -418,8 +415,8 @@ importers: specifier: ^17.0.39 version: 17.0.39 dedent: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^1.5.1 + version: 1.5.1 jest: specifier: ^29.6.2 version: 29.6.2(@types/node@17.0.39) @@ -559,8 +556,8 @@ importers: specifier: ^7.20.1 version: 7.20.1 dedent: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^1.5.1 + version: 1.5.1 packages/linaria: dependencies: @@ -707,12 +704,9 @@ importers: specifier: ^8.3.11 version: 8.4.14 devDependencies: - '@types/dedent': - specifier: ^0.7.0 - version: 0.7.0 dedent: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^1.5.1 + version: 1.5.1 prettier: specifier: ^3.0.3 version: 3.0.3 @@ -762,9 +756,6 @@ importers: '@types/babel__traverse': specifier: ^7.20.1 version: 7.20.1 - '@types/dedent': - specifier: ^0.7.0 - version: 0.7.0 '@types/jest': specifier: ^28.1.0 version: 28.1.0 @@ -772,8 +763,8 @@ importers: specifier: ^17.0.39 version: 17.0.39 dedent: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^1.5.1 + version: 1.5.1 jest: specifier: ^29.6.2 version: 29.6.2(@types/node@17.0.39) @@ -876,8 +867,8 @@ importers: specifier: ^4.3.4 version: 4.3.4 dedent: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^1.5.1 + version: 1.5.1 esbuild: specifier: ^0.15.16 version: 0.15.16 @@ -933,9 +924,6 @@ importers: '@types/debug': specifier: ^4.1.8 version: 4.1.8 - '@types/dedent': - specifier: ^0.7.0 - version: 0.7.0 '@types/jest': specifier: ^28.1.0 version: 28.1.0 @@ -1003,9 +991,24 @@ importers: '@types/babel__traverse': specifier: ^7.20.1 version: 7.20.1 + '@types/jest': + specifier: ^28.1.0 + version: 28.1.0 '@types/node': specifier: ^17.0.39 version: 17.0.39 + dedent: + specifier: ^1.5.1 + version: 1.5.1 + jest: + specifier: ^29.6.2 + version: 29.6.2(@types/node@17.0.39) + ts-jest: + specifier: ^29.1.1 + version: 29.1.1(@babel/core@7.22.15)(babel-jest@29.6.2)(esbuild@0.18.20)(jest@29.6.2)(typescript@5.2.2) + typescript: + specifier: ^5.2.2 + version: 5.2.2 packages/vite: dependencies: @@ -1133,8 +1136,8 @@ importers: specifier: ^1.1.1 version: 1.1.1 dedent: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^1.5.1 + version: 1.5.1 escape-html: specifier: ^1.0.3 version: 1.0.3 @@ -4481,10 +4484,6 @@ packages: '@types/ms': 0.7.31 dev: true - /@types/dedent@0.7.0: - resolution: {integrity: sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==} - dev: true - /@types/enhanced-resolve@3.0.7: resolution: {integrity: sha512-H23Fzk0BCz4LoKq1ricnLSRQuzoXTv57bGUwC+Cn84kKPaoHIS7bhFhfy4DzMeSBxoXc6jFziYoqpCab1U511w==} dependencies: @@ -7555,9 +7554,6 @@ packages: engines: {node: '>=0.10'} dev: false - /dedent@0.7.0: - resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} - /dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -7565,7 +7561,6 @@ packages: peerDependenciesMeta: babel-plugin-macros: optional: true - dev: true /deep-equal@1.0.1: resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} @@ -10731,6 +10726,7 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + requiresBuild: true /is-finalizationregistry@1.0.2: resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} @@ -11344,7 +11340,7 @@ packages: resolution: {integrity: sha512-vnIGYEjoPSuRqV8W9t+Wow95SDp6KPX2Uf7EoeG9G99J2OVh7OSwpS4B6J0NfpEIpfkBNHlBZpA2rblEuEFhZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@babel/code-frame': 7.22.10 + '@babel/code-frame': 7.22.13 '@jest/types': 29.6.1 '@types/stack-utils': 2.0.1 chalk: 4.1.2 diff --git a/website/package.json b/website/package.json index 75ea08892..cbf5b7b32 100644 --- a/website/package.json +++ b/website/package.json @@ -28,7 +28,7 @@ "@linaria/react": "workspace:^", "@linaria/server": "workspace:^", "babel-plugin-file-loader": "^1.1.1", - "dedent": "^0.7.0", + "dedent": "^1.5.1", "escape-html": "^1.0.3", "ignore-styles": "^5.0.1", "koa": "^2.7.0", diff --git a/website/webpack.config.js b/website/webpack.config.js index a1ee6fa7c..ceb244290 100644 --- a/website/webpack.config.js +++ b/website/webpack.config.js @@ -20,7 +20,7 @@ module.exports = { }, plugins: [ new LinariaDebugPlugin({ - filename: 'linaria-debug.json', + dir: 'linaria-debug', print: true, }), new MiniCssExtractPlugin({ filename: 'styles.css' }),