Skip to content

Plugin proxy is unintentionally Thenable: get-trap default returns function wrapper for 'then' / 'catch' / 'finally' #8472

Description

@playopsstudio

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):

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):

// Before (buggy):
let pluginPromise: Promise<PluginType> | undefined;
const loadPlugin = (): Promise<PluginType> => {
  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:

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 #<issue-number> 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs reproductionneeds reproducible example to illustrate the issue

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions