Capacitor version
(The relevant code is the same on main as of writing; see linked source below.)
Platforms affected
- iOS
- Android
(In principle, any platform that throws on unknown method dispatch.)
Summary
The plugin Proxy get trap in core/src/runtime.ts returns a callable method wrapper for every property name that isn't in a small allowlist ($$typeof, toJSON, addListener, removeListener). That allowlist intentionally special-cases $$typeof so React's thenable detection (facebook/react#20030) doesn't misclassify a plugin as a Lazy / Promise / Thenable.
But the same shape causes a more dangerous bug at the language level: the ECMAScript runtime itself probes .then on every value it adopts into a Promise. Any code that puts a plugin proxy on the resolution path of a Promise — most commonly returning the proxy from an async function — triggers the PromiseResolveThenableJob abstract operation, which calls proxy.then(resolve, reject).
For the plugin proxy:
proxy.then returns a callable method wrapper (since then isn't allowlisted).
- The runtime classifies the proxy as a thenable.
proxy.then(resolve, reject) dispatches a then() method call across the bridge to the native plugin.
- The native plugin (e.g.
PushNotifications) has no then implementation, so it throws "PluginName.then()" is not implemented on <platform> (the exact format produced here — the not implemented branch).
Two effects:
(a) proxy.then(resolve, reject) rejects without calling resolve or reject, so the surrounding await hangs indefinitely. The user-side try/catch cannot reach this rejection.
(b) The rejection becomes an unhandled rejection, which Sentry / browser global handlers catch — but the actual call site has no idea anything went wrong, because the try/catch is dead.
This is the same root cause as the well-known $$typeof issue, but rather than React's userland thenable check it's the language's own thenable adoption machinery. So the special case for $$typeof does not cover it.
Reproduction
Minimal repro using a proxy that mimics the Capacitor plugin shape:
function makeNativeBridgePlugin(name) {
return new Proxy({}, {
get(_, prop) {
switch (prop) {
case '$$typeof':
case 'toJSON':
case 'addListener':
case 'removeListener':
return undefined;
default:
return (..._args) => {
throw new Error(`"${name}.${String(prop)}()" is not implemented on ios`);
};
}
},
});
}
async function getPlugin() {
return makeNativeBridgePlugin('PushNotifications'); // returns Proxy from async fn
}
(async () => {
console.log('before');
const plugin = await getPlugin(); // <- hangs forever; await never resolves
console.log('after', plugin);
})();
Run with Node 20+. Output: before — and then nothing. The await never completes. An unhandled-rejection handler (or a process.on('unhandledRejection', ...)) does see Error: "PushNotifications.then()" is not implemented on ios, but the surrounding async function is dead.
This reproduces the exact runtime behavior that broke our iOS push notification registration in production (every iOS install ran await getPushPlugin() which silently never returned, and we never registered an APNs token).
Real-world incident
In our app (vellum-ai/vellum-assistant-platform) we had a helper:
async function getPushPlugin() {
const mod = await import("@capacitor/push-notifications");
return mod.PushNotifications; // returns the Capacitor Proxy from an async fn
}
// ...
const PushNotifications = await getPushPlugin(); // hangs on iOS
await PushNotifications.requestPermissions(); // never reached
Result: APNs registration silently broken for every iOS user for ~3 days; we discovered it only because Sentry's global unhandled-rejection handler captured the inner exception.
We fixed it on our side by destructuring the import inline, so the proxy never crosses an async boundary:
const { PushNotifications } = await import("@capacitor/push-notifications");
…but that's a workaround for a footgun that any consumer can hit with no obvious warning sign.
The same bug class is documented for @capacitor-community/native-audio here: https://mloverflow.com/post/436853ed-2279-48d4-a037-8917cea14da2 ("Promise Assimilation Errors"), with the conclusion: "Critical: Never return the Capacitor plugin proxy from a Promise." It's surfaced repeatedly in third-party code over multiple Capacitor major versions, which suggests it warrants a defensive fix in core rather than guidance.
Suggested fix
Extend the switch in the plugin proxy get trap to also return undefined for then. Concretely, add a then case alongside the existing $$typeof case in core/src/runtime.ts (around the block at lines 165–180):
get(_, prop) {
switch (prop) {
// https://github.com/facebook/react/issues/20030
case '$$typeof':
return undefined;
+ // ECMAScript Promise thenable adoption probes `.then` on every value
+ // it resolves. If we return a callable wrapper here, the runtime
+ // classifies the plugin as a thenable, calls `proxy.then(resolve, reject)`,
+ // and that dispatches a no-op `then()` method to the native side,
+ // which throws and never resolves or rejects — silently hanging the
+ // outer `await`. Returning `undefined` makes the proxy a non-thenable,
+ // which is the correct semantics: a Capacitor plugin is not a Promise.
+ // See https://tc39.es/ecma262/#sec-promiseresolvethenablejob
+ case 'then':
+ return undefined;
case 'toJSON':
return () => ({});
case 'addListener':
return pluginHeader ? addListenerNative : addListener;
case 'removeListener':
return removeListener;
default:
return createPluginMethodWrapper(prop);
}
},
This is symmetric with the existing $$typeof workaround — both are runtime-level thenable/special-form probes that should never be answered with a callable. Capacitor plugins are not promises, so then access should not produce one.
Why this is safe
then is reserved at the language level; it cannot be a legitimate Capacitor plugin method name (no Capacitor plugin in the wild publicly exposes a then(...) method, and doing so would already be hostile to Promise-using callers).
- All four major Capacitor plugins (
PushNotifications, Camera, Geolocation, Filesystem) and the community plugins surveyed do not define a then method.
- Returning
undefined for .then makes the proxy non-thenable per ECMA-262 §25.6.4.5.1, so Promise.resolve(plugin) resolves to the proxy itself, exactly as if plugin were a plain object.
Promise.then(...) calls on actual promises returned by plugin methods (e.g. PushNotifications.register() returns a real Promise) are unaffected — .then on a real Promise is a Promise method, not a proxy get.
- Symmetric fix already shipped for
$$typeof (commit 4cbae41) confirms the maintainers consider userland thenable-probe interception within scope.
Alternative considered
Documenting the rule "never return a Capacitor plugin from an async function" in the README. We added an equivalent rule to our own internal AGENTS.md, but documentation alone has not been sufficient — the same bug class has independently bitten multiple Capacitor consumers (links above). A defensive check in the proxy is durable; documentation is fragile.
References
Capacitor version
(The relevant code is the same on
mainas of writing; see linked source below.)Platforms affected
(In principle, any platform that throws on unknown method dispatch.)
Summary
The plugin
Proxygettrap incore/src/runtime.tsreturns a callable method wrapper for every property name that isn't in a small allowlist ($$typeof,toJSON,addListener,removeListener). That allowlist intentionally special-cases$$typeofso React's thenable detection (facebook/react#20030) doesn't misclassify a plugin as a Lazy / Promise / Thenable.But the same shape causes a more dangerous bug at the language level: the ECMAScript runtime itself probes
.thenon every value it adopts into a Promise. Any code that puts a plugin proxy on the resolution path of a Promise — most commonly returning the proxy from anasyncfunction — triggers thePromiseResolveThenableJobabstract operation, which callsproxy.then(resolve, reject).For the plugin proxy:
proxy.thenreturns a callable method wrapper (sincethenisn't allowlisted).proxy.then(resolve, reject)dispatches athen()method call across the bridge to the native plugin.PushNotifications) has nothenimplementation, so it throws"PluginName.then()" is not implemented on <platform>(the exact format produced here — thenot implementedbranch).Two effects:
(a)
proxy.then(resolve, reject)rejects without callingresolveorreject, so the surroundingawaithangs indefinitely. The user-sidetry/catchcannot reach this rejection.(b) The rejection becomes an unhandled rejection, which Sentry / browser global handlers catch — but the actual call site has no idea anything went wrong, because the
try/catchis dead.This is the same root cause as the well-known
$$typeofissue, but rather than React's userland thenable check it's the language's own thenable adoption machinery. So the special case for$$typeofdoes not cover it.Reproduction
Minimal repro using a proxy that mimics the Capacitor plugin shape:
Run with Node 20+. Output:
before— and then nothing. Theawaitnever completes. An unhandled-rejection handler (or aprocess.on('unhandledRejection', ...)) does seeError: "PushNotifications.then()" is not implemented on ios, but the surrounding async function is dead.This reproduces the exact runtime behavior that broke our iOS push notification registration in production (every iOS install ran
await getPushPlugin()which silently never returned, and we never registered an APNs token).Real-world incident
In our app (
vellum-ai/vellum-assistant-platform) we had a helper:Result: APNs registration silently broken for every iOS user for ~3 days; we discovered it only because Sentry's global unhandled-rejection handler captured the inner exception.
We fixed it on our side by destructuring the import inline, so the proxy never crosses an
asyncboundary:…but that's a workaround for a footgun that any consumer can hit with no obvious warning sign.
The same bug class is documented for
@capacitor-community/native-audiohere: https://mloverflow.com/post/436853ed-2279-48d4-a037-8917cea14da2 ("Promise Assimilation Errors"), with the conclusion: "Critical: Never return the Capacitor plugin proxy from a Promise." It's surfaced repeatedly in third-party code over multiple Capacitor major versions, which suggests it warrants a defensive fix in core rather than guidance.Suggested fix
Extend the
switchin the plugin proxygettrap to also returnundefinedforthen. Concretely, add athencase alongside the existing$$typeofcase incore/src/runtime.ts(around the block at lines 165–180):get(_, prop) { switch (prop) { // https://github.com/facebook/react/issues/20030 case '$$typeof': return undefined; + // ECMAScript Promise thenable adoption probes `.then` on every value + // it resolves. If we return a callable wrapper here, the runtime + // classifies the plugin as a thenable, calls `proxy.then(resolve, reject)`, + // and that dispatches a no-op `then()` method to the native side, + // which throws and never resolves or rejects — silently hanging the + // outer `await`. Returning `undefined` makes the proxy a non-thenable, + // which is the correct semantics: a Capacitor plugin is not a Promise. + // See https://tc39.es/ecma262/#sec-promiseresolvethenablejob + case 'then': + return undefined; case 'toJSON': return () => ({}); case 'addListener': return pluginHeader ? addListenerNative : addListener; case 'removeListener': return removeListener; default: return createPluginMethodWrapper(prop); } },This is symmetric with the existing
$$typeofworkaround — both are runtime-level thenable/special-form probes that should never be answered with a callable. Capacitor plugins are not promises, sothenaccess should not produce one.Why this is safe
thenis reserved at the language level; it cannot be a legitimate Capacitor plugin method name (no Capacitor plugin in the wild publicly exposes athen(...)method, and doing so would already be hostile to Promise-using callers).PushNotifications,Camera,Geolocation,Filesystem) and the community plugins surveyed do not define athenmethod.undefinedfor.thenmakes the proxy non-thenable per ECMA-262 §25.6.4.5.1, soPromise.resolve(plugin)resolves to the proxy itself, exactly as ifpluginwere a plain object.Promise.then(...)calls on actual promises returned by plugin methods (e.g.PushNotifications.register()returns a real Promise) are unaffected —.thenon a real Promise is a Promise method, not a proxyget.$$typeof(commit4cbae41) confirms the maintainers consider userland thenable-probe interception within scope.Alternative considered
Documenting the rule "never return a Capacitor plugin from an
asyncfunction" in the README. We added an equivalent rule to our own internalAGENTS.md, but documentation alone has not been sufficient — the same bug class has independently bitten multiple Capacitor consumers (links above). A defensive check in the proxy is durable; documentation is fragile.References
PromiseResolveThenableJobfacebook/react#20030, Capacitor fix4cbae41#4356(hasOwnPropertyprobe — same class of issue with a different language hook; closednot_planned, but$$typeofshows the maintainers do special-case some hooks where the cost is low and the user-facing surprise is high)