From 4ea076305cea67c500a253464a70db157be1250e Mon Sep 17 00:00:00 2001 From: Shresthap21 Date: Thu, 5 Feb 2026 00:39:14 +0530 Subject: [PATCH 01/11] Fix: Track local dependencies in rspack.config.js for cache invalidation Fixes #14031 When rspack.config.js requires local files (like plugin modules), those files weren't tracked in Rspack's buildDependencies. This caused the cache to incorrectly reuse stale data, skipping plugin transform hooks on subsequent meteor runs. Solution: - Parse rspack.config.js to extract require/import statements - Resolve local file paths (with extension fallback) - Add resolved paths to buildDependencies array - Filter out node_modules and external packages Now when local plugin files change, Rspack properly invalidates the cache and calls transform hooks as expected. --- npm-packages/meteor-rspack/rspack.config.js | 94 +++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/npm-packages/meteor-rspack/rspack.config.js b/npm-packages/meteor-rspack/rspack.config.js index 09138424a7..9f7c93094e 100644 --- a/npm-packages/meteor-rspack/rspack.config.js +++ b/npm-packages/meteor-rspack/rspack.config.js @@ -39,6 +39,94 @@ function safeRequire(moduleName) { } } +/** + * Extract local file dependencies from a config file by parsing require/import statements + * @param {string} configFilePath - Path to the config file to parse + * @returns {string[]} - Array of absolute paths to local dependencies + */ +function extractLocalDependencies(configFilePath) { + if (!configFilePath || !fs.existsSync(configFilePath)) { + return []; + } + + try { + const content = fs.readFileSync(configFilePath, 'utf-8'); + const dependencies = []; + const configDir = path.dirname(configFilePath); + const projectDir = process.cwd(); + + // Regex patterns to match require() and import statements + const requirePattern = /require\s*\(\s*[`'"]([^`'"]+)[`'"]\s*\)/g; + const importPattern = /import\s+.*?\s+from\s+[`'"]([^`'"]+)[`'"]/g; + const dynamicImportPattern = /import\s*\(\s*[`'"]([^`'"]+)[`'"]\s*\)/g; + + // Extract all matches + let match; + const patterns = [requirePattern, importPattern, dynamicImportPattern]; + + for (const pattern of patterns) { + while ((match = pattern.exec(content)) !== null) { + const modulePath = match[1]; + const resolvedPath = resolveLocalModule(modulePath, configDir, projectDir); + if (resolvedPath) { + dependencies.push(resolvedPath); + } + } + } + + // Remove duplicates + return [...new Set(dependencies)]; + } catch (error) { + console.warn('[Rspack Cache] Failed to parse config dependencies:', error.message); + return []; + } +} + +/** + * Resolve a module path to an absolute path if it's a local file + * @param {string} modulePath - Module path from require/import statement + * @param {string} configDir - Directory containing the config file + * @param {string} projectDir - Project root directory + * @returns {string|null} - Resolved absolute path or null + */ +function resolveLocalModule(modulePath, configDir, projectDir) { + // Only process relative paths (starts with . or /) + if (!modulePath.startsWith('.') && !modulePath.startsWith('/')) { + return null; + } + + try { + let resolvedPath = path.resolve(configDir, modulePath); + + // Try common extensions if file doesn't exist as-is + if (!fs.existsSync(resolvedPath)) { + const extensions = ['.js', '.mjs', '.cjs', '.ts', '.json']; + for (const ext of extensions) { + const pathWithExt = resolvedPath + ext; + if (fs.existsSync(pathWithExt)) { + resolvedPath = pathWithExt; + break; + } + } + } + + // Verify file exists and is within project (not node_modules) + if (fs.existsSync(resolvedPath)) { + const normalizedResolved = path.normalize(resolvedPath); + const normalizedProject = path.normalize(projectDir); + + if (normalizedResolved.startsWith(normalizedProject) && + !normalizedResolved.includes('node_modules')) { + return resolvedPath; + } + } + } catch (error) { + // Silently ignore resolution errors + } + + return null; +} + // Persistent filesystem cache strategy function createCacheStrategy(mode, side, { projectConfigPath, configPath } = {}) { // Check for configuration files @@ -59,10 +147,16 @@ function createCacheStrategy(mode, side, { projectConfigPath, configPath } = {}) const yarnLockPath = path.join(process.cwd(), 'yarn.lock'); const hasYarnLock = fs.existsSync(yarnLockPath); + // Extract local dependencies from project config (e.g., plugin files) + const localDependencies = projectConfigPath + ? extractLocalDependencies(projectConfigPath) + : []; + // Build dependencies array const buildDependencies = [ ...(projectConfigPath ? [projectConfigPath] : []), ...(configPath ? [configPath] : []), + ...localDependencies, ...(hasTsconfig ? [tsconfigPath] : []), ...(hasBabelRcConfig ? [babelRcConfig] : []), ...(hasBabelJsConfig ? [babelJsConfig] : []), From c751cd3c453478ff93e75d226a35ea7ad61d049e Mon Sep 17 00:00:00 2001 From: Shresthap21 Date: Fri, 6 Feb 2026 16:58:00 +0530 Subject: [PATCH 02/11] Refactor: Use @swc/core AST parsing for dependency tracking - Replace regex-based parsing with @swc/core AST parsing - Create new helper file lib/localDependenciesHelpers.js - Support all import/export patterns (static, dynamic, re-exports) - Handle directory imports with index file resolution - Fix path resolution with symlink support and proper node_modules filtering - Address all maintainer and Copilot review feedback --- .../lib/localDependenciesHelpers.js | 180 ++++++++++++++++++ npm-packages/meteor-rspack/rspack.config.js | 89 +-------- 2 files changed, 181 insertions(+), 88 deletions(-) create mode 100644 npm-packages/meteor-rspack/lib/localDependenciesHelpers.js diff --git a/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js new file mode 100644 index 0000000000..cea36e9c92 --- /dev/null +++ b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js @@ -0,0 +1,180 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Extract local file dependencies from a config file by parsing require/import statements using AST + * @param {string} configFilePath - Path to the config file to parse + * @returns {string[]} - Array of absolute paths to local dependencies + */ +function extractLocalDependencies(configFilePath) { + if (!configFilePath || !fs.existsSync(configFilePath)) { + return []; + } + + try { + const swc = require('@swc/core'); + const content = fs.readFileSync(configFilePath, 'utf-8'); + const configDir = path.dirname(configFilePath); + const projectDir = process.cwd(); + const dependencies = []; + + // Parse the file into an AST + const ast = swc.parseSync(content, { + syntax: 'ecmascript', + dynamicImport: true, + target: 'es2020', + }); + + // Visit all nodes to find import/require statements + visitNode(ast, (node) => { + let modulePath = null; + + // Handle require() calls: require('./plugin') + if (node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.value === 'require' && + node.arguments.length > 0) { + const arg = node.arguments[0]; + if (arg.expression?.type === 'StringLiteral') { + modulePath = arg.expression.value; + } + } + + // Handle dynamic import() calls: import('./plugin') + if (node.type === 'CallExpression' && + node.callee.type === 'Import' && + node.arguments.length > 0) { + const arg = node.arguments[0]; + if (arg.expression?.type === 'StringLiteral') { + modulePath = arg.expression.value; + } + } + + // Handle static imports: import x from './plugin' + if (node.type === 'ImportDeclaration' && node.source?.type === 'StringLiteral') { + modulePath = node.source.value; + } + + // Handle export re-exports: export * from './plugin' + if (node.type === 'ExportAllDeclaration' && node.source?.type === 'StringLiteral') { + modulePath = node.source.value; + } + + // Handle named export re-exports: export { x } from './plugin' + if (node.type === 'ExportNamedDeclaration' && node.source?.type === 'StringLiteral') { + modulePath = node.source.value; + } + + // If we found a module path, try to resolve it + if (modulePath) { + const resolvedPath = resolveLocalModule(modulePath, configDir, projectDir); + if (resolvedPath) { + dependencies.push(resolvedPath); + } + } + }); + + // Remove duplicates + return [...new Set(dependencies)]; + } catch (error) { + console.warn('[Rspack Cache] Failed to parse config dependencies:', error.message); + return []; + } +} + +/** + * Recursively visit all nodes in an AST + * @param {Object} node - AST node + * @param {Function} callback - Function to call for each node + */ +function visitNode(node, callback) { + if (!node || typeof node !== 'object') { + return; + } + + callback(node); + + // Visit all properties of the node + for (const key in node) { + if (node.hasOwnProperty(key)) { + const value = node[key]; + if (Array.isArray(value)) { + value.forEach(child => visitNode(child, callback)); + } else if (typeof value === 'object') { + visitNode(value, callback); + } + } + } +} + +/** + * Resolve a module path to an absolute path if it's a local file + * @param {string} modulePath - Module path from require/import statement + * @param {string} configDir - Directory containing the config file + * @param {string} projectDir - Project root directory + * @returns {string|null} - Resolved absolute path or null + */ +function resolveLocalModule(modulePath, configDir, projectDir) { + // Only process relative paths (starts with . or ..) + if (!modulePath.startsWith('.')) { + return null; + } + + try { + let resolvedPath = path.resolve(configDir, modulePath); + + // Try common extensions if file doesn't exist as-is + if (!fs.existsSync(resolvedPath)) { + const extensions = ['.js', '.mjs', '.cjs', '.ts', '.json']; + let found = false; + + for (const ext of extensions) { + const pathWithExt = resolvedPath + ext; + if (fs.existsSync(pathWithExt)) { + resolvedPath = pathWithExt; + found = true; + break; + } + } + + // If not found with extension, try index files in directory + if (!found && fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { + for (const ext of extensions) { + const indexPath = path.join(resolvedPath, `index${ext}`); + if (fs.existsSync(indexPath)) { + resolvedPath = indexPath; + found = true; + break; + } + } + } + + // If still not found, return null + if (!found) { + return null; + } + } + + // Verify file is within project (not node_modules) + const resolvedReal = fs.realpathSync(resolvedPath); + const projectReal = fs.realpathSync(projectDir); + + const isWithinProject = + resolvedReal === projectReal || + resolvedReal.startsWith(projectReal + path.sep); + const hasNodeModulesSegment = resolvedReal.split(path.sep).includes('node_modules'); + + if (isWithinProject && !hasNodeModulesSegment) { + return resolvedPath; + } + } catch (error) { + // Silently ignore resolution errors + } + + return null; +} + +module.exports = { + extractLocalDependencies, + resolveLocalModule, +}; diff --git a/npm-packages/meteor-rspack/rspack.config.js b/npm-packages/meteor-rspack/rspack.config.js index 9f7c93094e..937659f7bb 100644 --- a/npm-packages/meteor-rspack/rspack.config.js +++ b/npm-packages/meteor-rspack/rspack.config.js @@ -23,6 +23,7 @@ const { disablePlugins, } = require('./lib/meteorRspackHelpers.js'); const { prepareMeteorRspackConfig } = require("./lib/meteorRspackConfigFactory"); +const { extractLocalDependencies } = require('./lib/localDependenciesHelpers.js'); // Safe require that doesn't throw if the module isn't found function safeRequire(moduleName) { @@ -39,94 +40,6 @@ function safeRequire(moduleName) { } } -/** - * Extract local file dependencies from a config file by parsing require/import statements - * @param {string} configFilePath - Path to the config file to parse - * @returns {string[]} - Array of absolute paths to local dependencies - */ -function extractLocalDependencies(configFilePath) { - if (!configFilePath || !fs.existsSync(configFilePath)) { - return []; - } - - try { - const content = fs.readFileSync(configFilePath, 'utf-8'); - const dependencies = []; - const configDir = path.dirname(configFilePath); - const projectDir = process.cwd(); - - // Regex patterns to match require() and import statements - const requirePattern = /require\s*\(\s*[`'"]([^`'"]+)[`'"]\s*\)/g; - const importPattern = /import\s+.*?\s+from\s+[`'"]([^`'"]+)[`'"]/g; - const dynamicImportPattern = /import\s*\(\s*[`'"]([^`'"]+)[`'"]\s*\)/g; - - // Extract all matches - let match; - const patterns = [requirePattern, importPattern, dynamicImportPattern]; - - for (const pattern of patterns) { - while ((match = pattern.exec(content)) !== null) { - const modulePath = match[1]; - const resolvedPath = resolveLocalModule(modulePath, configDir, projectDir); - if (resolvedPath) { - dependencies.push(resolvedPath); - } - } - } - - // Remove duplicates - return [...new Set(dependencies)]; - } catch (error) { - console.warn('[Rspack Cache] Failed to parse config dependencies:', error.message); - return []; - } -} - -/** - * Resolve a module path to an absolute path if it's a local file - * @param {string} modulePath - Module path from require/import statement - * @param {string} configDir - Directory containing the config file - * @param {string} projectDir - Project root directory - * @returns {string|null} - Resolved absolute path or null - */ -function resolveLocalModule(modulePath, configDir, projectDir) { - // Only process relative paths (starts with . or /) - if (!modulePath.startsWith('.') && !modulePath.startsWith('/')) { - return null; - } - - try { - let resolvedPath = path.resolve(configDir, modulePath); - - // Try common extensions if file doesn't exist as-is - if (!fs.existsSync(resolvedPath)) { - const extensions = ['.js', '.mjs', '.cjs', '.ts', '.json']; - for (const ext of extensions) { - const pathWithExt = resolvedPath + ext; - if (fs.existsSync(pathWithExt)) { - resolvedPath = pathWithExt; - break; - } - } - } - - // Verify file exists and is within project (not node_modules) - if (fs.existsSync(resolvedPath)) { - const normalizedResolved = path.normalize(resolvedPath); - const normalizedProject = path.normalize(projectDir); - - if (normalizedResolved.startsWith(normalizedProject) && - !normalizedResolved.includes('node_modules')) { - return resolvedPath; - } - } - } catch (error) { - // Silently ignore resolution errors - } - - return null; -} - // Persistent filesystem cache strategy function createCacheStrategy(mode, side, { projectConfigPath, configPath } = {}) { // Check for configuration files From 35fd9e6bdee41e15b91c4c87260ad7adc5bbd406 Mon Sep 17 00:00:00 2001 From: slegarraga <64795732+slegarraga@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:40:13 -0300 Subject: [PATCH 03/11] fix(accounts): fix operator precedence in passwordValidator for maxLength Due to JavaScript operator precedence, the expression: str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256 was parsed as: (str.length <= ...passwordMaxLength) || 256 which always evaluates to truthy (256) when passwordMaxLength is undefined, and also when the password exceeds a configured maxLength. Added parentheses so the fallback works correctly: str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256) Fixes #14072 --- packages/accounts-password/password_server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index cb7cb96112..1d09b38af8 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -290,7 +290,7 @@ Accounts._checkPasswordAsync = checkPasswordAsync; const passwordValidator = Match.OneOf( - Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256), { + Match.Where(str => Match.test(str, String) && str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)), { digest: Match.Where(str => Match.test(str, String) && str.length === 64), algorithm: Match.OneOf('sha-256') } From 790d0af0b8b598c97f6fb546b03813f47cf2f485 Mon Sep 17 00:00:00 2001 From: slegarraga <64795732+slegarraga@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:35:51 -0300 Subject: [PATCH 04/11] fix: also fix operator precedence in changePassword and add tests - Fix the same operator precedence bug in the changePassword handler (line 475) where passwordMaxLength || 256 was not properly grouped - Add unit tests to verify password length validation works correctly with the default 256 char limit when no custom maxLength is configured Addresses review feedback from @italojs --- packages/accounts-password/password_server.js | 2 +- packages/accounts-password/password_tests.js | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index 1d09b38af8..3564b474a5 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -472,7 +472,7 @@ Meteor.methods( Accounts.setPasswordAsync = async (userId, newPlaintextPassword, options) => { check(userId, String); - check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)); + check(newPlaintextPassword, Match.Where(str => Match.test(str, String) && str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256))); check(options, Match.Maybe({ logout: Boolean })); options = { logout: true, ...options }; diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 49f94544a0..4f8ab8e8fb 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1136,6 +1136,56 @@ if (Meteor.isClient) (() => { })(); +if (Meteor.isServer) { + Tinytest.add( + 'passwords - passwordValidator accepts passwords within default maxLength', + test => { + // A password of 256 chars (default max) should be accepted + const validPassword = 'a'.repeat(256); + test.isTrue( + Match.test(validPassword, Match.OneOf( + Match.Where(str => Match.test(str, String) && str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)), + { digest: Match.Where(str => Match.test(str, String) && str.length === 64), algorithm: Match.OneOf('sha-256') } + )), + 'Password of exactly 256 chars should be accepted' + ); + } + ); + + Tinytest.add( + 'passwords - passwordValidator rejects passwords exceeding default maxLength', + test => { + // A password of 257 chars should be rejected + const longPassword = 'a'.repeat(257); + test.isFalse( + Match.test(longPassword, Match.OneOf( + Match.Where(str => Match.test(str, String) && str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)), + { digest: Match.Where(str => Match.test(str, String) && str.length === 64), algorithm: Match.OneOf('sha-256') } + )), + 'Password exceeding 256 chars should be rejected' + ); + } + ); + + Tinytest.add( + 'passwords - passwordValidator operator precedence is correct for maxLength fallback', + test => { + // This test verifies the fix: without proper parentheses around the || operator, + // `str.length <= Meteor.settings?.packages?.accounts?.passwordMaxLength || 256` + // would evaluate as `(str.length <= undefined) || 256` which is always truthy (256), + // allowing passwords of any length. + const veryLongPassword = 'a'.repeat(1000); + test.isFalse( + Match.test(veryLongPassword, Match.OneOf( + Match.Where(str => Match.test(str, String) && str.length <= (Meteor.settings?.packages?.accounts?.passwordMaxLength || 256)), + { digest: Match.Where(str => Match.test(str, String) && str.length === 64), algorithm: Match.OneOf('sha-256') } + )), + 'Very long password (1000 chars) should be rejected when no custom maxLength is configured' + ); + } + ); +} + if (Meteor.isServer) (() => { Tinytest.add('passwords - setup more than one onCreateUserHook', test => { From 457838808cbe2ab5ba06646aed47d3a80d69649a Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 3 Mar 2026 10:00:12 -0600 Subject: [PATCH 05/11] refactor(accounts-base): remove unnecessary await on Array.find calls --- packages/accounts-base/accounts_server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index e8a884c87b..27f5784ff5 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -1680,13 +1680,13 @@ const defaultResumeLoginHandler = async (accounts, options) => { // {hashedToken, when} for a hashed token or {token, when} for an // unhashed token. let oldUnhashedStyleToken; - let token = await user.services.resume.loginTokens.find(token => + let token = user.services.resume.loginTokens.find(token => token.hashedToken === hashedToken ); if (token) { oldUnhashedStyleToken = false; } else { - token = await user.services.resume.loginTokens.find(token => + token = user.services.resume.loginTokens.find(token => token.token === options.resume ); oldUnhashedStyleToken = true; From 81e2bd7e5000f097c24c5e06cc372b922c658880 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Thu, 19 Mar 2026 23:07:38 +0100 Subject: [PATCH 06/11] ddp-server: clear subscription references after async onStop callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only _callStopCallbacks becomes async — _deactivate uses .then() to clear _session and _documents after callbacks complete, avoiding async propagation up the call stack. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ddp-server/livedata_server.js | 37 +++++++----- packages/ddp-server/livedata_server_tests.js | 61 +++++++++++++++++++- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 3f5efceafb..df167f94c4 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -1050,23 +1050,33 @@ Object.assign(Subscription.prototype, { // removed messages for the published objects; if that is necessary, call // _removeAllDocuments first. _deactivate: function() { - var self = this; - if (self._deactivated) + if (this._deactivated) return; - self._deactivated = true; - self._callStopCallbacks(); + this._deactivated = true; + this._callStopCallbacks().then(() => { + // Break reference chains to allow GC of the Session and its data. + // Without this, deactivated subscriptions retain live references + // to the (now-closed) session indefinitely. + this._session = null; + this._documents = new Map(); + }); Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact( "livedata", "subscriptions", -1); }, - _callStopCallbacks: function () { - var self = this; - // Tell listeners, so they can clean up - var callbacks = self._stopCallbacks; - self._stopCallbacks = []; - callbacks.forEach(function (callback) { - callback(); - }); + _callStopCallbacks: async function () { + // In Meteor 3, onStop callbacks can be async (e.g. observeHandle.stop() + // returns a Promise). We must await each one so that observer teardown + // completes before the subscription is considered fully deactivated. + const callbacks = this._stopCallbacks; + this._stopCallbacks = []; + for (const callback of callbacks) { + try { + await callback(); + } catch (e) { + Meteor._debug("Exception in onStop callback:", e); + } + } }, // Send remove messages for every document. @@ -1145,8 +1155,7 @@ Object.assign(Subscription.prototype, { // destroyed but the deferred call to _deactivateAllSubscriptions hasn't // happened yet. _isDeactivated: function () { - var self = this; - return self._deactivated || self._session.inQueue === null; + return this._deactivated || !this._session || this._session.inQueue === null; }, /** diff --git a/packages/ddp-server/livedata_server_tests.js b/packages/ddp-server/livedata_server_tests.js index 15b0349e87..6b66c6bc67 100644 --- a/packages/ddp-server/livedata_server_tests.js +++ b/packages/ddp-server/livedata_server_tests.js @@ -593,4 +593,63 @@ function getTestConnections(test) { function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file +} + +// ============================================================================ +// Async onStop cleanup tests (memory leak fix) +// ============================================================================ + +const asyncCleanupTracker = {}; + +Meteor.publish('test_async_onstop_cleanup', function (trackerId) { + this.onStop(async function () { + await new Promise(resolve => setTimeout(resolve, 50)); + asyncCleanupTracker[trackerId] = true; + }); + this.ready(); +}); + +Tinytest.addAsync( + 'livedata server - async onStop callbacks complete on unsubscribe', + async function (test) { + const trackerId = Random.id(); + asyncCleanupTracker[trackerId] = false; + + const { clientConn } = await getTestConnections(test); + const sub = clientConn.subscribe('test_async_onstop_cleanup', trackerId); + await sleep(100); + + sub.stop(); + await sleep(200); + + test.isTrue( + asyncCleanupTracker[trackerId], + 'Async onStop callback should have completed' + ); + + clientConn.disconnect(); + delete asyncCleanupTracker[trackerId]; + } +); + +Tinytest.addAsync( + 'livedata server - async onStop callbacks complete on disconnect', + async function (test) { + const trackerId = Random.id(); + asyncCleanupTracker[trackerId] = false; + + const { clientConn } = await getTestConnections(test); + clientConn.subscribe('test_async_onstop_cleanup', trackerId); + await sleep(100); + + clientConn.disconnect(); + await sleep(300); + + test.isTrue( + asyncCleanupTracker[trackerId], + 'Async onStop callback should have completed on disconnect' + ); + + delete asyncCleanupTracker[trackerId]; + } +); \ No newline at end of file From 345bcbc2e11bbf47b2807944811d3ab91793a47d Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Fri, 20 Mar 2026 10:29:29 +0100 Subject: [PATCH 07/11] tests: use waitUntil instead of sleep for async onStop tests --- packages/ddp-server/livedata_server_tests.js | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/ddp-server/livedata_server_tests.js b/packages/ddp-server/livedata_server_tests.js index 6b66c6bc67..dbb49c63ee 100644 --- a/packages/ddp-server/livedata_server_tests.js +++ b/packages/ddp-server/livedata_server_tests.js @@ -617,10 +617,18 @@ Tinytest.addAsync( const { clientConn } = await getTestConnections(test); const sub = clientConn.subscribe('test_async_onstop_cleanup', trackerId); - await sleep(100); + + await waitUntil( + () => sub.ready(), + { description: 'subscription is ready' } + ); sub.stop(); - await sleep(200); + + await waitUntil( + () => asyncCleanupTracker[trackerId] === true, + { description: 'async onStop callback completed after unsubscribe' } + ); test.isTrue( asyncCleanupTracker[trackerId], @@ -640,10 +648,18 @@ Tinytest.addAsync( const { clientConn } = await getTestConnections(test); clientConn.subscribe('test_async_onstop_cleanup', trackerId); - await sleep(100); + + await waitUntil( + () => clientConn.status().connected, + { description: 'client is connected' } + ); clientConn.disconnect(); - await sleep(300); + + await waitUntil( + () => asyncCleanupTracker[trackerId] === true, + { description: 'async onStop callback completed after disconnect' } + ); test.isTrue( asyncCleanupTracker[trackerId], From 37816052f0285ba84bc24e4bb92d73ebe658ae0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 12:23:27 +0200 Subject: [PATCH 08/11] Add @swc/core as a peer dependency --- npm-packages/meteor-rspack/package-lock.json | 265 ++++++++++++++++++- npm-packages/meteor-rspack/package.json | 3 +- 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/npm-packages/meteor-rspack/package-lock.json b/npm-packages/meteor-rspack/package-lock.json index 7792758d69..739b4cd71d 100644 --- a/npm-packages/meteor-rspack/package-lock.json +++ b/npm-packages/meteor-rspack/package-lock.json @@ -16,7 +16,8 @@ }, "peerDependencies": { "@rspack/cli": ">=1.3.0", - "@rspack/core": ">=1.3.0" + "@rspack/core": ">=1.3.0", + "@swc/core": ">=1.3.0" } }, "node_modules/@discoveryjs/json-ext": { @@ -491,6 +492,268 @@ "node": ">=16.0.0" } }, + "node_modules/@swc/core": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz", + "integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.21", + "@swc/core-darwin-x64": "1.15.21", + "@swc/core-linux-arm-gnueabihf": "1.15.21", + "@swc/core-linux-arm64-gnu": "1.15.21", + "@swc/core-linux-arm64-musl": "1.15.21", + "@swc/core-linux-ppc64-gnu": "1.15.21", + "@swc/core-linux-s390x-gnu": "1.15.21", + "@swc/core-linux-x64-gnu": "1.15.21", + "@swc/core-linux-x64-musl": "1.15.21", + "@swc/core-win32-arm64-msvc": "1.15.21", + "@swc/core-win32-ia32-msvc": "1.15.21", + "@swc/core-win32-x64-msvc": "1.15.21" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz", + "integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz", + "integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz", + "integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz", + "integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz", + "integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz", + "integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz", + "integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz", + "integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz", + "integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz", + "integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz", + "integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz", + "integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", diff --git a/npm-packages/meteor-rspack/package.json b/npm-packages/meteor-rspack/package.json index 2bcbb679c2..a880bf3f19 100644 --- a/npm-packages/meteor-rspack/package.json +++ b/npm-packages/meteor-rspack/package.json @@ -14,6 +14,7 @@ }, "peerDependencies": { "@rspack/cli": ">=1.3.0", - "@rspack/core": ">=1.3.0" + "@rspack/core": ">=1.3.0", + "@swc/core": ">=1.3.0" } } From 5741635e307a7027e3080c640ea8544f7f3c5a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 12:24:29 +0200 Subject: [PATCH 09/11] replace `hasOwnProperty` usage with `Object.prototype.hasOwnProperty.call` for better reliability. --- npm-packages/meteor-rspack/lib/localDependenciesHelpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js index cea36e9c92..e1ad321fbb 100644 --- a/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js +++ b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js @@ -96,7 +96,7 @@ function visitNode(node, callback) { // Visit all properties of the node for (const key in node) { - if (node.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(node, key)) { const value = node[key]; if (Array.isArray(value)) { value.forEach(child => visitNode(child, callback)); From 17bfd1950dc3da7092fb91903c50388798de5941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 12:26:31 +0200 Subject: [PATCH 10/11] simplify logic for resolving module paths with better extension and directory handling --- .../lib/localDependenciesHelpers.js | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js index e1ad321fbb..b43199d87e 100644 --- a/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js +++ b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js @@ -122,10 +122,26 @@ function resolveLocalModule(modulePath, configDir, projectDir) { try { let resolvedPath = path.resolve(configDir, modulePath); + const extensions = ['.js', '.mjs', '.cjs', '.ts', '.json']; - // Try common extensions if file doesn't exist as-is - if (!fs.existsSync(resolvedPath)) { - const extensions = ['.js', '.mjs', '.cjs', '.ts', '.json']; + // If the path exists as-is, check if it's a directory needing index resolution + if (fs.existsSync(resolvedPath)) { + if (fs.statSync(resolvedPath).isDirectory()) { + let found = false; + for (const ext of extensions) { + const indexPath = path.join(resolvedPath, `index${ext}`); + if (fs.existsSync(indexPath)) { + resolvedPath = indexPath; + found = true; + break; + } + } + if (!found) { + return null; + } + } + } else { + // Try common extensions if file doesn't exist as-is let found = false; for (const ext of extensions) { @@ -137,18 +153,6 @@ function resolveLocalModule(modulePath, configDir, projectDir) { } } - // If not found with extension, try index files in directory - if (!found && fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { - for (const ext of extensions) { - const indexPath = path.join(resolvedPath, `index${ext}`); - if (fs.existsSync(indexPath)) { - resolvedPath = indexPath; - found = true; - break; - } - } - } - // If still not found, return null if (!found) { return null; From 8cc1efa34bce5ec7477d6158ba28669828a83397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 16:28:46 +0200 Subject: [PATCH 11/11] add demo unplugin to Rspack config and E2E tests for cache tracking --- dev/modern-tools/rspack/E2E_COVERAGE.md | 10 +++++ tools/e2e-tests/apps/react/package.json | 1 + .../apps/react/plugins/demo-unplugin.js | 30 +++++++++++++ tools/e2e-tests/apps/react/rspack.config.cjs | 3 +- tools/e2e-tests/react.test.js | 42 ++++++++++++++++--- 5 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 tools/e2e-tests/apps/react/plugins/demo-unplugin.js diff --git a/dev/modern-tools/rspack/E2E_COVERAGE.md b/dev/modern-tools/rspack/E2E_COVERAGE.md index 1199c73a50..f01e58e4c3 100644 --- a/dev/modern-tools/rspack/E2E_COVERAGE.md +++ b/dev/modern-tools/rspack/E2E_COVERAGE.md @@ -40,6 +40,9 @@ Core React integration with custom Meteor local directory. | React + JSX environment detection | Run, Prod, Test, Build | | Image assets load (generated + public + background) | Run, Prod | | `Meteor.disablePlugins` suppresses rspack plugins | Run, Prod, Test, Build | +| Unplugin transform hook fires on first run (fresh cache) | Init | +| Unplugin factory created on cached run — #14031 regression | Run | +| Unplugin transform + buildDependencies tracking in production | Prod | | Custom rspack config (`rspack.config.cjs`) | All | | HMR works in dev, disabled in prod | Run, Prod | @@ -227,6 +230,12 @@ Several apps import specific npm packages to verify that Meteor + Rspack handles | `node:buffer` | `imports/api/links.js` | Node.js built-in via `node:` protocol in shared client/server code — must be ignored on client without errors | | `@react-email/components` | `imports/emails/TestEmail.jsx` | JSX-heavy ESM package with many subpath exports | +### react (`apps/react/plugins/demo-unplugin.js`) + +| Package | Reason | +|---------|--------| +| `unplugin` | Unplugin transform hook integration — validates rspack cache tracks plugin dependency files (#14031) | + ### babel (`apps/babel/server/apollo.js`) | Package | Reason | @@ -269,6 +278,7 @@ Where each feature is tested across apps and skeletons. | Babel compiler plugin | react-router | | | TypeScript type checking | typescript | | | Meteor.disablePlugins | react | | +| Unplugin transform with cache (#14031) | react | | | Custom package dirs | react-router | | | CoffeeScript compilation | coffeescript | coffeescript | | Server-only (no client) | server-only | | diff --git a/tools/e2e-tests/apps/react/package.json b/tools/e2e-tests/apps/react/package.json index c57d7575f1..748641428a 100644 --- a/tools/e2e-tests/apps/react/package.json +++ b/tools/e2e-tests/apps/react/package.json @@ -11,6 +11,7 @@ "@babel/runtime": "^7.23.5", "@swc/helpers": "^0.5.17", "meteor-node-stubs": "^1.2.12", + "unplugin": "^2.3.2", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/tools/e2e-tests/apps/react/plugins/demo-unplugin.js b/tools/e2e-tests/apps/react/plugins/demo-unplugin.js new file mode 100644 index 0000000000..e570040b16 --- /dev/null +++ b/tools/e2e-tests/apps/react/plugins/demo-unplugin.js @@ -0,0 +1,30 @@ +const { createUnplugin } = require('unplugin'); + +const demoUnplugin = createUnplugin(() => { + console.log('[demo-unplugin][factory-created]'); + return { + name: 'demo-unplugin', + transformInclude(id) { + // Only process app source files, skip node_modules and .meteor + if (id.includes('node_modules') || id.includes('.meteor')) { + return false; + } + const ok = + id.endsWith('.tsx') || + id.endsWith('.ts') || + id.endsWith('.jsx') || + id.endsWith('.js'); + + if (ok) { + console.log('[demo-unplugin][transformInclude]', id, '=> true'); + } + return ok; + }, + transform(code, id) { + console.log('[demo-unplugin][transform-enter]', id); + return { code, map: null }; + }, + }; +}); + +module.exports = { demoRspackPlugin: demoUnplugin.rspack }; diff --git a/tools/e2e-tests/apps/react/rspack.config.cjs b/tools/e2e-tests/apps/react/rspack.config.cjs index a28fcc5ac3..91a774dcdb 100644 --- a/tools/e2e-tests/apps/react/rspack.config.cjs +++ b/tools/e2e-tests/apps/react/rspack.config.cjs @@ -1,6 +1,7 @@ const { defineConfig } = require('@meteorjs/rspack'); const path = require('path'); const CustomConsoleLogPlugin = require("./plugins/CustomConsoleLogPlugin"); +const { demoRspackPlugin } = require("./plugins/demo-unplugin"); /** * Rspack configuration for Meteor projects. @@ -39,6 +40,6 @@ module.exports = defineConfig(Meteor => { }, ], }, - plugins: [new CustomConsoleLogPlugin()], + plugins: [new CustomConsoleLogPlugin(), demoRspackPlugin()], }; }); diff --git a/tools/e2e-tests/react.test.js b/tools/e2e-tests/react.test.js index f10ececa9c..2f13cdfa46 100644 --- a/tools/e2e-tests/react.test.js +++ b/tools/e2e-tests/react.test.js @@ -78,6 +78,13 @@ describe('React App Bundling /', () => { buildDir: "_build-local-custom", env: { METEOR_LOCAL_DIR: ".meteor/local-custom" }, customAssertions: { + afterInit: async ({ result }) => { + // Verify unplugin transform hook is called on first run (fresh cache) + await waitForMeteorOutput( + result.outputLines, + /.*\[demo-unplugin\]\[transform-enter\].*/ + ); + }, afterRun: async ({ result, tempDir }) => { const appDir = tempDir; // testMeteorRspackBundler uses tempDir as appDir if not monorepo @@ -95,11 +102,20 @@ describe('React App Bundling /', () => { await assertImagesExistAndLoad(); // Check custom plugin is disabled with Meteor.disablePlugins + // Use specific log prefix to avoid matching the filename in buildDependencies await waitForMeteorOutput( result.outputLines, - /.*CustomConsoleLogPlugin.*/, + /.*\[CustomConsoleLogPlugin\].*/, { negate: true } ); + + // Verify unplugin factory is still created on second run (with cache) + // This confirms the plugin is loaded and active even when rspack uses + // cached transform results (#14031 regression test) + await waitForMeteorOutput( + result.outputLines, + /.*\[demo-unplugin\]\[factory-created\].*/ + ); }, afterRunRebuildClient: async ({ allConsoleLogs }) => { // Check for HMR output as enabled by default @@ -115,11 +131,24 @@ describe('React App Bundling /', () => { await assertImagesExistAndLoad(); // Check custom plugin is disabled with Meteor.disablePlugins + // Use specific log prefix to avoid matching the filename in buildDependencies await waitForMeteorOutput( result.outputLines, - /.*CustomConsoleLogPlugin.*/, + /.*\[CustomConsoleLogPlugin\].*/, { negate: true } ); + + // Verify demo-unplugin.js is tracked in rspack buildDependencies (#14031) + await waitForMeteorOutput( + result.outputLines, + /.*plugins\/demo-unplugin\.js.*/ + ); + + // Verify unplugin transform hook fires in production (separate cache version) + await waitForMeteorOutput( + result.outputLines, + /.*\[demo-unplugin\]\[transform-enter\].*/ + ); }, afterRunProductionRebuildClient: async ({ allConsoleLogs }) => { // Check for HMR to not be enabled in production-like mode @@ -133,9 +162,10 @@ describe('React App Bundling /', () => { await waitForReactEnvs(result.outputLines); // Check custom plugin is disabled with Meteor.disablePlugins + // Use specific log prefix to avoid matching the filename in buildDependencies await waitForMeteorOutput( result.outputLines, - /.*CustomConsoleLogPlugin.*/, + /.*\[CustomConsoleLogPlugin\].*/, { negate: true } ); }, @@ -143,9 +173,10 @@ describe('React App Bundling /', () => { await waitForReactEnvs(result.outputLines); // Check custom plugin is disabled with Meteor.disablePlugins + // Use specific log prefix to avoid matching the filename in buildDependencies await waitForMeteorOutput( result.outputLines, - /.*CustomConsoleLogPlugin.*/, + /.*\[CustomConsoleLogPlugin\].*/, { negate: true } ); }, @@ -153,9 +184,10 @@ describe('React App Bundling /', () => { await waitForReactEnvs(result.outputLines, { isJsxEnabled: true }); // Check custom plugin is disabled with Meteor.disablePlugins + // Use specific log prefix to avoid matching the filename in buildDependencies await waitForMeteorOutput( result.outputLines, - /.*CustomConsoleLogPlugin.*/, + /.*\[CustomConsoleLogPlugin\].*/, { negate: true } ); },