ionic-team / ionic-team/capacitor
Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally'
- Dominant language
- TypeScript
- Stars
- 16.7k
- Forks
- 1.3k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 10
Description
# Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally'
**Labels (suggested)**: `bug`, `core`, `needs review`
---
## Summary
The plugin proxy created by `registerPlugin()` in `core/src/runtime.ts:162-181` is unintentionally a JavaScript Thenable. Its `get` trap returns `createPluginMethodWrapper(prop)` for any property name not in the switch — including `then`, `catch`, and `finally`. Per ECMAScript Promise spec, any object with a callable `.then` is treated as a Thenable, causing surprising behavior when a plugin proxy passes through Promise resolution.
## Mechanism
`core/src/runtime.ts:165-178` (verified against main branch):
```typescript
get(_, prop) {
switch (prop) {
// https://github.com/facebook/react/issues/20030
case '$$typeof':
return undefined;
case 'toJSON':
return () => ({});
case 'addListener':
return pluginHeader ? addListenerNative : addListener;
case 'removeListener':
return removeListener;
default:
return createPluginMethodWrapper(prop);
}
},
```
When code holds a plugin proxy as a Promise resolution value, the JS engine's Thenable check fires:
1. Consumer code: `const pluginPromise = import("some-capacitor-plugin").then((module) => module.SomePlugin);` — common dynamic-import pattern for lazy plugin loading
2. The `.then((module) => module.SomePlugin)` callback returns the plugin proxy
3. Promise resolution machinery checks: is this returned value a Thenable? Reads `.then` on the proxy
4. Proxy get-trap returns `createPluginMethodWrapper("then")` — a function — JS engine treats proxy as Thenable
5. Promise resolution invokes `proxy.then(resolve, reject)` to chain
6. The wrapper dispatches `cap.nativePromise(pluginName, "then", resolve)` to the native bridge
7. No native plugin has `@PluginMethod` for `then` → bridge rejects with `"PluginName.then() is not implemented on android"` (or platform-equivalent)
8. **The outer `Promise.resolve(proxy)` may hang** (resolve/reject never invoked by the wrapper) AND/OR the inner cap.nativePromise rejection bubbles to `window.unhandledrejection`
The `// https://github.com/facebook/react/issues/20030` comment at line 167 already acknowledges that proxies-passed-into-framework-machinery need explicit short-circuits (React's `$$typeof` check). The same issue applies to Promise machinery's `.then`/`.catch`/`.finally` checks — these need to return `undefined` for the same architectural reason.
## Affected versions (empirically verified)
- `@capacitor/core@8.3.1` (confirmed via `dist/index.js:156-172`)
- `@capacitor/core@9.0.0-alpha.2` (confirmed via `dist/index.js:159-167` — same proxy shape)
- `main` branch as of fetch (confirmed via `core/src/runtime.ts:165-178`)
Bug appears unchanged from at least 8.x through 9.0.0-alpha; likely present in 7.x and earlier.
## Real-world failure surfaces
In a private game-runtime codebase using Capacitor 8.3.1 + multiple community plugins, we hit three production-surface symptoms of this bug, all sharing the same root cause:
1. **`await adMob.prepareForAds()` race-throw**: AdMob plugin proxy used via dynamic-import-then pattern; sometimes the await sees `"AdMob.then() is not implemented on android"` rejection before the actual `prepareForAds()` invocation. Timing-dependent due to `cap.nativePromise` dispatch race.
2. **`await iap.initialize()` hang**: IAP plugin proxy (different community plugin); same dynamic-import-then pattern; the `Promise.resolve(proxy)` chain calls `proxy.then(resolve, reject)` which never resolves the outer promise → hangs forever.
3. **`await createMonetizationBridge(...)` hang**: an async factory function whose internal `addListener` calls on the IAP proxy trigger the same bug at a different call surface.
We shipped a consumer-side workaround pattern (container-wrap):
```typescript
// Before (buggy):
let pluginPromise: Promise | undefined;
const loadPlugin = (): Promise => {
pluginPromise ??= import("some-plugin").then((m) => m.Plugin);
return pluginPromise;
};
// After (workaround; defeats the Thenable check):
let pluginPromise: Promise<{ plugin: PluginType }> | undefined;
const loadPlugin = (): Promise<{ plugin: PluginType }> => {
pluginPromise ??= import("some-plugin").then((m) => ({ plugin: m.Plugin }));
return pluginPromise;
};
// Consumer:
const { plugin } = await loadPlugin();
await plugin.someMethod();
```
The container-wrap works because plain objects without a `.then` property are not Thenable. But this is fragile (every consumer-side dynamic-import must remember to wrap; a single oversight reintroduces the bug class).
## Proposed fix
Add `then`, `catch`, and `finally` to the get-trap switch returning `undefined`:
```typescript
get(_, prop) {
switch (prop) {
// https://github.com/facebook/react/issues/20030
case '$$typeof':
return undefined;
case 'toJSON':
return () => ({});
case 'addListener':
return pluginHeader ? addListenerNative : addListener;
case 'removeListener':
return removeListener;
// Promise-machinery short-circuit. See # for failure modes.
case 'then':
case 'catch':
case 'finally':
return undefined;
default:
return createPluginMethodWrapper(prop);
}
},
```
Matches the architectural precedent of `$$typeof` (React framework-machinery short-circuit). Zero runtime behavior change for any code that doesn't accidentally feed the proxy through Promise resolution; eliminates the latent footgun for code that does.
Contributor guide
Assessment
This issue has not been assessed yet.