Skip to content

Add unstable_middleware and unstable_priorityMiddleware, deprecate enhanceMiddleware (#2031) - #2031

Open
vzaidman wants to merge 1 commit into
mainfrom
export-D123474267
Open

vzaidman wants to merge 1 commit into
mainfrom
export-D123474267

Conversation

@vzaidman

@vzaidman vzaidman commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Revisits the 2023 deprecation of server.enhanceMiddleware.

Replaces server.enhanceMiddleware with two declarative middleware lists, unstable_middleware after Metro and unstable_priorityMiddleware before it, accepted both in metro.config.js and by runServer, and unstable_onServerCreated, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when waitForBundler is set).

Why

Deprecation

  • The deprecation argued that middleware belongs with the tools that start Metro, configured through runServer, rather than in Metro's config. That assumed whoever adds a handler controls the runServer call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use enhanceMiddleware, and so does Expo CLI itself (see below).

Current State

Two different parties add handlers to Metro's server, and each controls a different entry point:

  • Frameworks (RN Community CLI, Expo CLI) start the server by calling runServer or createConnectMiddleware, and pass in the handlers every app needs, such as React Native's dev middleware.
  • Apps, and libraries that ship config wrappers, never call runServer. metro.config.js is all they control. An app composes the wrappers of the libraries it uses:
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
  • So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
  • The replacement, runServer's unstable_extraMiddleware, only reaches frameworks and only works for them. So enhanceMiddleware stayed the only hook for most users, and Expo CLI itself still relies on it, with a TODO acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's enhanceMiddleware with a wrapper that runs the app's hook first:
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};

So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.

  • Function-shaped hooks don't compose well. Every contributor has to capture and call the previous enhanceMiddleware. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared connect just to mount a path. e.g:
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);

Passing metroMiddleware instead of the prev(...) call still works locally, but drops everything prev installed.

  • Neither metro.config.js nor runServer's options allowed access to Metro's server instance besides enhanceMiddleware, which was abused as a lifecycle callback in a very dirty way. For example, internally, fb-metro-cli was using this hack:
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};

unstable_onServerCreated is intended to fix it.

Why two flat lists

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to unstable_middleware and prepends to unstable_priorityMiddleware. mergeConfig combines the lists in that order, so these are equivalent:

config.server.unstable_middleware = [...config.server.unstable_middleware, fallback];
config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware];

mergeConfig(config, {
  server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]},
});

Entries copied from the earlier config, e.g. by spreading it in mergeConfig's function form, are kept once.

Naming

unstable_middleware is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name unstable_priorityMiddleware. Both are singular, like enhanceMiddleware: each list composes into one middleware on the server.

Shapes considered

  • {pre: [], post: []}: every contributor clones and merges a nested object, and a missed spread drops the other list:
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
  • One list of {path, handler, pre: true} entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost

API

type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
  • server.unstable_middleware / server.unstable_priorityMiddleware (config, for apps and wrappers) and the runServer options of the same names (for frameworks) are ReadonlyArray<ServerMiddleware>, defaulting to empty.
  • Entries mirror connect's app.use: a handler, or a [path, handler] tuple mounted with connect semantics.
  • unstable_middleware runs only for requests Metro passed on with next(). unstable_priorityMiddleware runs before Metro.
  • Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. createConnectMiddleware applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
  • runServer's unstable_onServerCreated(metroServer) runs after Metro's server is created and before the HTTP server listens.
  • mergeConfig combines unstable_middleware and unstable_priorityMiddleware as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
  • server.enhanceMiddleware keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites enhanceMiddleware itself, so Expo users see this warning until Expo CLI moves to unstable_priorityMiddleware.
  • runServer's unstable_extraMiddleware is kept as a deprecated alias, mounted before unstable_priorityMiddleware, so React Native keeps working against this version. unstable_priorityMiddleware covers it: its handlers run in the same place, before Metro, and it also accepts [path, handler] entries.

Changelog:

  • [Feature]: Add server.unstable_middleware and server.unstable_priorityMiddleware config options, and runServer options of the same names, to add middleware after and before Metro's own.
  • [Feature]: mergeConfig combines server.unstable_middleware and server.unstable_priorityMiddleware from both configs: a later config's unstable_middleware runs last and its unstable_priorityMiddleware runs first.
  • [Feature]: Add runServer option unstable_onServerCreated, called with Metro's server instance before the HTTP server listens.
  • [Deprecated]: server.enhanceMiddleware logs a deprecation warning when set to anything other than its default.
  • [Deprecated]: runServer's unstable_extraMiddleware option. Use unstable_priorityMiddleware, which runs the same middleware in the same place.

Differential Revision: D123474267

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 7, 2026
@meta-codesync

meta-codesync Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

@vzaidman has exported this pull request. If you are a Meta employee, you can view the originating Diff in D123474267.

@huntie huntie left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As always, naming 😅

Comment thread docs/API.md Outdated
Comment thread packages/metro/src/index.flow.js
Comment thread packages/metro-config/src/defaults/index.js Outdated
@meta-codesync meta-codesync Bot changed the title Add unstable_pre/postMiddlewares, deprecate enhanceMiddleware Add unstable_pre/postMiddlewares, deprecate enhanceMiddleware (#2031) Oct 8, 2026
meta-codesync Bot pushed a commit that referenced this pull request Oct 8, 2026
Summary:

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, one before Metro and one after it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it prepends its pre-middlewares and appends its post-middlewares. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_preMiddlewares = [auth, ...config.server.unstable_preMiddlewares];
config.server.unstable_postMiddlewares = [...config.server.unstable_postMiddlewares, fallback];

mergeConfig(config, {
  server: {unstable_preMiddlewares: [auth], unstable_postMiddlewares: [fallback]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_preMiddlewares` / `server.unstable_postMiddlewares` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- Pre-middlewares run before Metro. Post-middlewares run only for requests Metro passed on with `next()`.
- Request order: framework pre -> config pre -> Metro -> config post -> framework post. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_preMiddlewares` and `unstable_postMiddlewares` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_preMiddlewares`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_preMiddlewares`, so React Native keeps working against this version. `unstable_preMiddlewares` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` config options, and `runServer` options of the same names, to add middleware before and after Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` from both configs: a later config's pre-middlewares run first and its post-middlewares run last.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_preMiddlewares`, which runs the same middlewares in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from fda5f04 to db15fcc Compare October 8, 2026 10:13
meta-codesync Bot pushed a commit that referenced this pull request Oct 8, 2026
Summary:

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, one before Metro and one after it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it prepends its pre-middlewares and appends its post-middlewares. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_preMiddlewares = [auth, ...config.server.unstable_preMiddlewares];
config.server.unstable_postMiddlewares = [...config.server.unstable_postMiddlewares, fallback];

mergeConfig(config, {
  server: {unstable_preMiddlewares: [auth], unstable_postMiddlewares: [fallback]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_preMiddlewares` / `server.unstable_postMiddlewares` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- Pre-middlewares run before Metro. Post-middlewares run only for requests Metro passed on with `next()`.
- Request order: framework pre -> config pre -> Metro -> config post -> framework post. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_preMiddlewares` and `unstable_postMiddlewares` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_preMiddlewares`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_preMiddlewares`, so React Native keeps working against this version. `unstable_preMiddlewares` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` config options, and `runServer` options of the same names, to add middleware before and after Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` from both configs: a later config's pre-middlewares run first and its post-middlewares run last.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_preMiddlewares`, which runs the same middlewares in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from db15fcc to 78bfb22 Compare October 8, 2026 10:36
meta-codesync Bot pushed a commit that referenced this pull request Oct 8, 2026
Summary:
Pull Request resolved: #2031

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, one before Metro and one after it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it prepends its pre-middlewares and appends its post-middlewares. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_preMiddlewares = [auth, ...config.server.unstable_preMiddlewares];
config.server.unstable_postMiddlewares = [...config.server.unstable_postMiddlewares, fallback];

mergeConfig(config, {
  server: {unstable_preMiddlewares: [auth], unstable_postMiddlewares: [fallback]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_preMiddlewares` / `server.unstable_postMiddlewares` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- Pre-middlewares run before Metro. Post-middlewares run only for requests Metro passed on with `next()`.
- Request order: framework pre -> config pre -> Metro -> config post -> framework post. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_preMiddlewares` and `unstable_postMiddlewares` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_preMiddlewares`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_preMiddlewares`, so React Native keeps working against this version. `unstable_preMiddlewares` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` config options, and `runServer` options of the same names, to add middleware before and after Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` from both configs: a later config's pre-middlewares run first and its post-middlewares run last.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_preMiddlewares`, which runs the same middlewares in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from 78bfb22 to c9778db Compare October 8, 2026 10:38
@huntie
huntie requested a review from robhogan October 8, 2026 12:23
@meta-codesync meta-codesync Bot changed the title Add unstable_pre/postMiddlewares, deprecate enhanceMiddleware (#2031) Add unstable_middleware and unstable_priorityMiddleware, deprecate enhanceMiddleware (#2031) Oct 8, 2026
meta-codesync Bot pushed a commit that referenced this pull request Oct 8, 2026
…hanceMiddleware (#2031)

Summary:

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_middleware = [...config.server.unstable_middleware, fallback];
config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware];

mergeConfig(config, {
  server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Naming**

`unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro.
- Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from c9778db to 8cfad73 Compare October 8, 2026 15:59
meta-codesync Bot pushed a commit that referenced this pull request Oct 8, 2026
…hanceMiddleware (#2031)

Summary:
Pull Request resolved: #2031

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_middleware = [...config.server.unstable_middleware, fallback];
config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware];

mergeConfig(config, {
  server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Naming**

`unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro.
- Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from 8cfad73 to f106b9d Compare October 8, 2026 16:05
meta-codesync Bot pushed a commit that referenced this pull request Oct 8, 2026
…hanceMiddleware (#2031)

Summary:

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_middleware = [...config.server.unstable_middleware, fallback];
config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware];

mergeConfig(config, {
  server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Naming**

`unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro.
- Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from f106b9d to 163e8bb Compare October 8, 2026 17:41
…hanceMiddleware (#2031)

Summary:
Pull Request resolved: #2031

Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd).

Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set).

### Why

**Deprecation**

- The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below).

**Current State**

Two different parties add handlers to Metro's server, and each controls a different entry point:
  - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware.
  - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses:
```
// my-lib/metro.js
exports.withMyLib = config => ({
  ...config,
  server: {...config.server /* , my-lib's handlers */},
});

// metro.config.js
const {getDefaultConfig} = require('expo/metro-config');
const {withNativeWind} = require('nativewind/metro');
const {withMyLib} = require('my-lib/metro');

module.exports = withMyLib(
  withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}),
);
```
- So Metro needs the option in both places, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other.
- The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first:
```
// TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks
const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware;
metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => {
  if (customEnhanceMiddleware) {
    metroMiddleware = customEnhanceMiddleware(metroMiddleware, server);
  }
  return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last
};
```
So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
- Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g:
```
const prev = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) =>
  require('connect')()
    .use('/my-endpoint', myHandler)
    .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware);
```
Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed.
- Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack:
```
let observerInstalled = false;
config.server.enhanceMiddleware = (metroMiddleware, metroServer) => {
  if (!observerInstalled) {
    installBundlerObserver(metroServer);
    observerInstalled = true;
  }
  return metroMiddleware;
};
```
`unstable_onServerCreated` is intended to fix it.

**Why two flat lists**

Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent:
```
config.server.unstable_middleware = [...config.server.unstable_middleware, fallback];
config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware];

mergeConfig(config, {
  server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]},
});
```
Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once.

**Naming**

`unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server.

**Shapes considered**
- `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list:
```
config.server.middlewares = {
  ...config.server.middlewares,
  pre: [auth, ...config.server.middlewares.pre],
  post: [...config.server.middlewares.post, fallback],
};
```
- One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:
```
// wrapper
config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}];
// app
config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}];
// result: [auth (pre), fallback, appPre (pre), /x appPost]
// runs as: auth -> appPre -> Metro -> fallback -> /x appPost
```

**API**
```
type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>;
```
- `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty.
- Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics.
- `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro.
- Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.
- `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens.
- `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.
- `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`.
- `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries.

Changelog:

* **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own.
* **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first.
* **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens.
* **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default.
* **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place.

Differential Revision: D123474267
@meta-codesync
meta-codesync Bot force-pushed the export-D123474267 branch from 163e8bb to afb3b67 Compare October 8, 2026 17:44
@vzaidman
vzaidman requested a review from huntie October 9, 2026 09:53

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants