# @amplitude/element-selector

Shared element-selector algorithm consumed by the autocapture SDK plugin, the app.amplitude.com tagging UI, and the Chrome extension visual tagger. One implementation, three surfaces, identical output.

## Why it exists

The legacy autocapture `cssPath` algorithm anchored on any element id it found, which broke selector stability whenever a framework generated an id like `:r5:` (React's `useId`) or `radix-A1B2C3` (Radix UI). It also leaned heavily on classes for sibling disambiguation, which broke selector stability whenever a library applied transient state classes like `swiper-slide-active` or `Mui-selected`.

This package replaces that algorithm with a strategy-chain orchestrator that filters autogenerated ids and library state classes out of the anchor selection, then disambiguates siblings positionally via `:nth-of-type`. The result: selectors that survive component re-mounts, carousel interactions, hover/focus state churn, and minor design tweaks.

See the design doc at `packages/plugin-autocapture-browser/element-selector-strategy-v1-no-classes.md` for the full background and rationale.

## Install

This package lives in the `Amplitude-TypeScript` monorepo and is published to npm as `@amplitude/element-selector`. Consumers in this repo declare it as a workspace dependency:

```json
{
  "dependencies": {
    "@amplitude/element-selector": "workspace:*"
  }
}
```

The only runtime dependency is `tslib`; diagnostics accept any logger with `warn` and `debug` methods.

## Usage

### Standalone consumer

```ts
import { createSelectorEngine, resolveSelectorConfig } from '@amplitude/element-selector';

// Start with the documented defaults. Remote-config values can be applied as
// they arrive — `resolveSelectorConfig` is safe to call repeatedly.
const config = resolveSelectorConfig({ enabled: true });
const engine = createSelectorEngine(config);

// Generate a selector for any element. The engine returns the best selector
// it can produce, falling back to a positional walk when no strategy applies.
document.addEventListener('click', (event) => {
  const target = event.target as Element;
  const selector = engine.generate(target);
  console.log(`Clicked: ${selector}`);
});
```

### Autocapture SDK plugin

The autocapture plugin instantiates one engine per `init()` call, threads its `loggerProvider` through for diagnostic logs, and forwards remote-config updates via `updateConfig`:

```ts
import { createSelectorEngine, resolveSelectorConfig } from '@amplitude/element-selector';

const engine = createSelectorEngine(resolveSelectorConfig(remote, config.loggerProvider), {
  logger: config.loggerProvider,
});

config.remoteConfigClient.subscribe(
  'configs.analyticsSDK.browserSDK.autocapture.elementSelector',
  'all',
  (next) => {
    engine.updateConfig(resolveSelectorConfig(next, config.loggerProvider));
  },
);
```

### Chrome extension visual tagger

The extension reads the page's already-live engine from `window.amplitude.elementSelector` (when present) and subscribes to `onConfigChange` so its preview stays in sync with the customer's runtime config:

```ts
const pageEngine = (window as any).amplitude?.elementSelector;
if (pageEngine) {
  const unsubscribe = pageEngine.onConfigChange((nextConfig) => {
    rerenderTaggingUI(nextConfig);
  });
  // Call unsubscribe() on extension teardown.
}
```

## How the algorithm works

The orchestrator walks from the clicked element up through its ancestors, trying each strategy at each level in priority order. The first strategy that produces a unique-resolving selector wins; the trail of elements between the anchor and the original target is expressed as a positional descent.

### Strategy chain

| Order | Strategy | Anchor format | When it applies |
|-------|----------|--------------|-----------------|
| 1 | `explicitTrackingAttribute` | `[data-amp-track-id="value"]` | Customer set the tracking attribute with a non-empty value |
| 2 | `stableId` | `tag#id` | Element has an id, the id isn't suppressed, and it doesn't match any autogenerated-id pattern |

### Fallback

When neither strategy produces a unique selector anywhere on the walk, the engine invokes `fallbackCssPath`. The fallback walks from the target back up to `<html>`, expressing each step as `tag:nth-of-type(n)` and terminating early at the first unique id that survives the autogenerated-id filter.

### Sibling disambiguation: `:nth-of-type`, never classes

The fallback does not use classes for sibling disambiguation. Classes are runtime-volatile in modern frameworks — Tailwind utilities change with design tweaks, CSS-in-JS hashes change every build, and library state classes (`swiper-slide-active`, `is-open`, `Mui-selected`) move between elements as the user interacts. Positional descent via `:nth-of-type(n)` is stable across all of these.

## Defaults

### `enabled: false`

v1 ships dormant. Autocapture continues to use the legacy `cssPath` until the remote-config payload flips `enabled` to `true` for an org.

### `explicitTrackingAttribute: "data-amp-track-id"`

Customers can set this attribute on an element to anchor the selector on it explicitly. Setting it to an empty string is a suppression signal — the engine treats the element's id as if it didn't exist.

### `autogeneratedIdPatterns`

The default list filters out ids that look generated rather than authored:

| Pattern | Catches | Example |
|---------|---------|---------|
| `^:r[0-9a-z]+:$` | React `useId` | `:r5:`, `:rk:` |
| `^radix-` | Radix UI primitives | `radix-A1B2C3` |
| `^headlessui-` | Headless UI components | `headlessui-menu-button-:r5:` |
| `^mui-` | MUI internal id prefix | `mui-12345` |
| `^[a-f0-9]{16,}$` (`i`) | UUIDs without hyphens, build hashes | `a9b8c7d6e5f4321b` |
| `-\d{8,}$` | Trailing long digit runs | `session-1700000000` |
| `\d{4,}` | Any 4+ consecutive digits | `product-2134`, `row-1234` |

### `unstableClassPatterns`

The default list (used by the fallback) catches classes that look stable in name but aren't stable in practice — Tailwind utilities, CSS-in-JS hashes (Emotion, styled-components, CSS modules, styled-jsx), and library runtime state classes (Swiper, MUI, Radix, BEM-style `is-active`).

### `maxAncestorWalkDepth: undefined`

By default the walk runs all the way to `<html>`. Set this to a positive integer to cap the depth — useful for catastrophically deep DOMs where uniqueness checks could become expensive.

## Remote config

The remote-config payload shape is defined in `src/types.ts` as `ElementSelectorRemoteConfig` and mirrored as a JSON Schema at `schema/element-selector-remote-config.schema.json` for the dashboard's remote-config editor and backend validation.

Customer-provided pattern lists fully **replace** the defaults — they're not merged. An empty array (`autogeneratedIdPatterns: []`) is an explicit "disable filtering" signal, not a "use defaults" signal. Invalid regex strings are dropped (with a warn log) rather than crashing the engine.

## Diagnostics

Every path that catches an error accepts an optional `ElementSelectorLogger`, which is structurally compatible with the SDK's `@amplitude/analytics-core` logger provider. When wired up, the engine surfaces:

- `warn` — invalid regex strings in remote config, subscriber exceptions during `updateConfig` fan-out
- `debug` — malformed selectors produced by a strategy

When no logger is provided, the engine stays silent — preserving fire-and-forget semantics for consumers that don't want diagnostic noise.

## Interactive testbed

Run the package's testbed page locally to click around the engine against the canonical scenarios, edit remote-config payloads in real time, and watch the diagnostic logger output:

```bash
pnpm --filter @amplitude/element-selector testbed
# → builds the package, serves the testbed at http://localhost:4567
```

The testbed lives at `packages/element-selector/testbed/` and imports the real built engine from `lib/esm/index.js` — there's no parallel implementation that can drift from the package. Every scenario from `test/scenarios/` is rendered live; clicking any element shows the emitted selector, the strategy that won, and whether `querySelector` round-trips back to the clicked element.

## Testing

```bash
pnpm --filter @amplitude/element-selector test
pnpm --filter @amplitude/element-selector lint
pnpm --filter @amplitude/element-selector build
```

The test suite includes:

- **Primitive unit tests** for the pattern packs, helpers, strategies, config resolver, orchestrator, fallback, and engine factory
- **Schema drift guard** — asserts the JSON Schema and the `ElementSelectorRemoteConfig` TypeScript type stay in sync
- **Scenarios corpus** — a parameterized differential test that runs the engine over hand-picked fixtures (Swiper, MUI, Tailwind, semantic HTML, CSS-in-JS soup, etc.) and asserts both the expected selector and a `querySelector` round-trip
- **Property tests** — generate random DOM trees with mixed stable / autogen ids and class soup, assert the engine always emits a selector that uniquely resolves back to the target

## Design references

- Design doc: `packages/plugin-autocapture-browser/element-selector-strategy-v1-no-classes.md`
- Implementation plan: phased PR breakdown in the design doc's "Rollout" section
