Skip to content

feat: add registerPlugin so a plugin can be added after the client starts - #393

Open
abelonogov-ld wants to merge 8 commits into
mainfrom
andrey/register-plugin
Open

feat: add registerPlugin so a plugin can be added after the client starts#393
abelonogov-ld wants to merge 8 commits into
mainfrom
andrey/register-plugin

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Plugins could only be supplied through LDConfig, so an integration that learns about a plugin later — or that wants to instrument a client it did not configure — had no way in. This adds LDClient.registerPlugin(Plugin), mirroring the method the .NET and Flutter SDKs already expose.

  • Hooks go live only once register succeeds. This follows the .NET ordering, and it differs from configuration-time registration, where a plugin's hooks are active before register is called. The consequence is that a plugin whose registration failed never contributes hooks, and a plugin's own hooks do not observe the evaluations or identify calls its register makes.
  • EnvironmentMetadata is now retained on the instance rather than living in a local map during init, so a plugin registered later is handed the same environment description as one configured up front. The init loop switches from a Map<LDClient, EnvironmentMetadata> to a List<LDClient> accordingly.
  • Failures are contained. A throwing getHooks leaves the plugin unregistered; a throwing register yields a failure result passed to onPluginsReady. Neither propagates to the caller. Reading the plugin's name for the log message tolerates metadata that itself throws, so reporting one failure cannot raise another out of the handler reporting it.

Registration applies to the one client it is called on, so a multi-environment setup means calling it per environment.

Test plan

Eight cases added to LDClientPluginsTest, all passing on an API 36 emulator alongside the three pre-existing plugin tests (11 total):

  • registerPluginPassesClientAndEnvironmentMetadata
  • registerPluginActivatesBundledHooks
  • registerPluginDoesNotRunTheRegisteringPluginsOwnHooks
  • registerPluginReportsSuccessToOnPluginsReady
  • registerPluginDoesNotRegisterPluginWhoseGetHooksThrows
  • registerPluginToleratesRegisterThrowing
  • registerPluginRejectsNullPlugin
  • registerPluginAppliesOnlyToTheClientItIsCalledOn
  • testDebugUnitTest green

Note

Overview
Adds LDClient.registerPlugin(Plugin) so integrations can attach plugins after the client is created, aligned with other LaunchDarkly mobile SDKs. EnvironmentMetadata is kept on each client instance so late registration gets the same environment description as config-time plugins.

Plugin registration is unified in a private registerPlugins path used by both LDConfig plugins at init and registerPlugin. For each plugin: getHooks, then register, then all successful hooks are activated in one batch via new HookRunner.addHooks. Hooks are not active during any plugin’s register call, and plugins that throw from getHooks or register contribute no hooks (errors are logged only). Init-time registration still invokes deprecated onPluginsReady for the configured batch; registerPlugin does not call it and applies only to the client it was invoked on.

Plugin.onPluginsReady is marked @Deprecated. Instrumentation tests cover failure isolation, hook timing, metadata, null rejection, and multi-environment scoping.

Reviewed by Cursor Bugbot for commit 0940c6b. Bugbot is set up for automated code reviews on this repo. Configure here.

…arts

Plugins could only be supplied through LDConfig, so an integration that
learns about a plugin later — or that wants to instrument a client it did
not configure — had no way in.

Hooks go live only once register succeeds, matching the .NET ordering, so a
plugin whose registration failed never contributes hooks and a plugin's own
hooks do not observe its register call. Retaining EnvironmentMetadata on the
instance lets a plugin registered later be handed the same environment
description as one configured up front.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner August 21, 2026 19:53
…y/android-client-sdk into andrey/register-plugin

* 'andrey/register-plugin' of ssh://github.com/launchdarkly/android-client-sdk:
  fix: make the close/reset timer tests deterministic
* @deprecated This reports on a batch of plugins registered together, so it has no meaning for
* {@link LDClient#registerPlugin(Plugin)}, which registers a single plugin and does
* not call it. Do work that needs the client in {@link #register} instead, which
* both paths call.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This deprecation reason should not talk about specific usages of the abstract class.

You can just say "onPluginsReady was necessary in the past, but has since been determined to be problematic and is being removed in the next major version."

abelonogov-ld added a commit to launchdarkly/ios-client-sdk that referenced this pull request Aug 25, 2026
…arts (#522)

## Summary

Plugins could only be supplied through `LDConfig`, so an integration
that learns about a plugin later — or that wants to instrument a client
it did not configure — had no way in. This adds
`LDClient.registerPlugin(_:)`, matching the method the .NET and Flutter
SDKs already expose and the one being added to the Android SDK in
launchdarkly/android-client-sdk#393.

- **Hooks became mutable, because registration after start cannot extend
a constant array.** They now live in a `private var` behind an `NSLock`
and are replaced rather than mutated in place. Each series reads a
snapshot once into a local, so the hooks a series ends with are the
hooks it began with — were the array read again mid-series, a hook
registered in between would be handed an `after` stage for a series
whose `before` stage it was never in. The three read sites
(`evaluateWithHooks`, `executeBeforeIdentifyHooks`,
`executeAfterTrackHooks`) all take that snapshot up front.
- **Hooks go live only once `register` returns.** This matches the
Android and .NET ordering, and differs from configuration-time
registration where a plugin's hooks are active before `register` is
called. So a plugin's own hooks will not observe evaluations or identify
calls its `register` makes; everything afterwards does run them.
- **`EnvironmentMetadata` is retained on the instance**, so a plugin
registered later is handed the same environment description as one
configured up front. This also removes the duplicate construction that
previously existed in both `start` and `collectHooks`.

Registration applies to the one client it is called on, so a
multi-environment setup means calling it per environment.

Note that the iOS `Plugin` protocol has no `onPluginsReady`, so unlike
Android this path is just `getHooks` then `register` then activate.

## Test plan

Five cases added to `LDClientPluginsSpec`, all passing on an iPhone 16e
simulator alongside the two pre-existing plugin tests (7 total):

- [x] `testRegisterPluginPassesClientAndEnvironmentMetadata`
- [x] `testRegisterPluginActivatesBundledHooks`
- [x] `testRegisterPluginDoesNotRunTheRegisteringPluginsOwnHooks`
- [x] `testRegisterPluginHooksRunAfterConfiguredHooks`
- [x] `testRegisterPluginAppliesOnlyToTheClientItIsCalledOn`
- [x] SwiftLint introduces no new violations in the touched files

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds **`LDClient.registerPlugin(_:)`** so integrations can attach
plugins after `start`, aligned with other LaunchDarkly mobile SDKs.
Plugins still use `LDConfig.plugins` at startup; this path is for late
discovery or clients the app did not configure.
> 
> **Hook list is now mutable and thread-safe.** Hooks move from a fixed
`let` array to `storedHooks` guarded by `NSLock`, with `addHooks`
appending plugin hooks at registration. Evaluation, identify, and track
paths each **snapshot** `hooks` once per series so mid-flight
`registerPlugin` cannot pair an `after` stage with a `before` the new
hook never ran.
> 
> **`environmentMetadata` is stored on the client** and passed to
`register` and `getHooks`, so late plugins get the same environment
description as config-time plugins and duplicate metadata construction
in `start` / `collectHooks` is removed.
> 
> `registerPlugin` calls `getHooks` and activates hooks **before**
`Plugin.register`, matching config-time ordering (including work done
inside `register`). Registration applies only to the client instance you
call it on (multi-environment setups need per-client calls).
> 
> Tests in `LDClientPluginsSpec` cover metadata handoff, hook
activation, ordering after config hooks, hooks during `register`, and
per-environment isolation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1c5db77. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b1bb297. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants