Merge branch 'release-3.4.1' into typescript-tailwind-skeleton

This commit is contained in:
Nacho Codoñer
2026-03-30 17:56:09 +02:00
committed by GitHub
13 changed files with 688 additions and 25 deletions

View File

@@ -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 | |

View File

@@ -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,
};

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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] : []),

View File

@@ -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;

View File

@@ -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 => {

View File

@@ -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;
},
/**

View File

@@ -593,4 +593,79 @@ function getTestConnections(test) {
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// ============================================================================
// 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];
}
);

View File

@@ -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"
},

View File

@@ -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 };

View File

@@ -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()],
};
});

View File

@@ -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 }
);
},