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/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js new file mode 100644 index 0000000000..b43199d87e --- /dev/null +++ b/npm-packages/meteor-rspack/lib/localDependenciesHelpers.js @@ -0,0 +1,184 @@ +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 (Object.prototype.hasOwnProperty.call(node, 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); + 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) { + const pathWithExt = resolvedPath + ext; + if (fs.existsSync(pathWithExt)) { + resolvedPath = pathWithExt; + 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/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" } } diff --git a/npm-packages/meteor-rspack/rspack.config.js b/npm-packages/meteor-rspack/rspack.config.js index c784879958..793512b1d3 100644 --- a/npm-packages/meteor-rspack/rspack.config.js +++ b/npm-packages/meteor-rspack/rspack.config.js @@ -27,6 +27,7 @@ const { } = require('./lib/meteorRspackHelpers.js'); const { loadUserAndOverrideConfig } = require('./lib/meteorRspackConfigHelpers.js'); const { prepareMeteorRspackConfig } = require("./lib/meteorRspackConfigFactory"); +const { extractLocalDependencies } = require('./lib/localDependenciesHelpers.js'); // Safe require that doesn't throw if the module isn't found @@ -70,10 +71,16 @@ function createCacheStrategy( 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] : []), diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index cd4e816460..f76fce23d0 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; diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index f409516c59..3b55a0ede8 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -1138,6 +1138,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 => { 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..dbb49c63ee 100644 --- a/packages/ddp-server/livedata_server_tests.js +++ b/packages/ddp-server/livedata_server_tests.js @@ -593,4 +593,79 @@ 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 waitUntil( + () => sub.ready(), + { description: 'subscription is ready' } + ); + + sub.stop(); + + await waitUntil( + () => asyncCleanupTracker[trackerId] === true, + { description: 'async onStop callback completed after unsubscribe' } + ); + + 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 waitUntil( + () => clientConn.status().connected, + { description: 'client is connected' } + ); + + clientConn.disconnect(); + + await waitUntil( + () => asyncCleanupTracker[trackerId] === true, + { description: 'async onStop callback completed after disconnect' } + ); + + test.isTrue( + asyncCleanupTracker[trackerId], + 'Async onStop callback should have completed on disconnect' + ); + + delete asyncCleanupTracker[trackerId]; + } +); \ No newline at end of file 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 } ); },