Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 118 additions & 35 deletions .claude/skills/port-widget/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,51 @@ description: Port or introduce an InstantSearch widget or connector-driven featu

# Port InstantSearch Widgets Across Flavors

## Start with the repo audit
## Start with the audit

- Run `python3 scripts/audit_widget_coverage.py <widget-kebab-name>` from this skill folder before editing.
- Use `--repo /path/to/instantsearch` if your current working directory is not inside the InstantSearch repo.
- Treat placeholder Vue failures in `packages/vue-instantsearch/src/__tests__/common-widgets.test.js` or `common-connectors.test.js` as evidence that the connector exists but the Vue wrapper still needs work.
Always run the audit before editing — it tells you what is actually missing,
catches variant widgets, and points to the right placeholder strings to remove.

```bash
# What porting work is open across the whole repo?
python3 .claude/skills/port-widget/scripts/audit_widget_coverage.py --gaps

# Detailed scorecard for one widget (use the kebab-case directory name)
python3 .claude/skills/port-widget/scripts/audit_widget_coverage.py <widget>
```

Pass `--repo /path/to/instantsearch` if your CWD is outside this repo.

The audit's `Notes` section already calls out:

- variant widgets (e.g. `menu-select` reuses `connectMenu`/`useMenu`)
- special widgets that live outside the normal layout (e.g. `dynamic-widgets`
exports its React component from `react-instantsearch-core/src/components/`)
- Vue placeholder strings still throwing `"X is not supported in Vue InstantSearch"`
- recommendation/chat widgets that need the `Hits.js` render-function pattern in Vue

Trust those notes — they encode pitfalls that have already burned past porting work.

## Current gap shape (as of this skill version)

Useful to know which patterns dominate so you can plan from the right precedent.
Re-run `--gaps` to see live state.

- **React widgets missing**: `numeric-menu`, `menu-select` (variant of `menu`),
`rating-menu` (needs hook + widget).
- **Vue components missing**: the recommendation/chat family —
`chat`, `filter-suggestions`, `frequently-bought-together`, `looking-similar`,
`related-products`, `trending-facets`, `trending-items`, plus an
`autocomplete` test-only gap. All have connectors and React widgets already;
Vue is the only missing flavor.
- **Test-suite-only gaps**: several established widgets (`hits`, `search-box`,
`clear-refinements`, etc.) lack `tests/common/connectors/<widget>/`. Low
priority — add only when changing the connector contract.

## Layer map

- Connector: `packages/instantsearch.js/src/connectors/<widget>/connect<Pascal>.ts`
- JS widget: `packages/instantsearch.js/src/widgets/<widget>/<widget>.tsx`
- JS widget: `packages/instantsearch.js/src/widgets/<widget>/<widget>.tsx` (or `.ts` for `dynamic-widgets`)
- React hook: `packages/react-instantsearch-core/src/connectors/use<Pascal>.ts`
- React widget: `packages/react-instantsearch/src/widgets/<Pascal>.tsx`
- Optional React UI: `packages/react-instantsearch/src/ui/<Pascal>.tsx`
Expand All @@ -24,47 +59,95 @@ description: Port or introduce an InstantSearch widget or connector-driven featu

## Variant widgets

Some widgets reuse another widget's connector with different defaults or UI. For example, `menuSelect` uses `connectMenu`/`useMenu` (not a dedicated `connectMenuSelect`). The audit will show `no` for connector and hook rows — this is expected. The `$$widgetType` still differs (`ais.menuSelect` vs `ais.menu`). When porting a variant widget, skip connector/hook creation and reuse the existing hook directly in the widget file.
Some widgets reuse another widget's connector with different defaults or UI:

Known variants: `menuSelect` → `connectMenu`/`useMenu`.
| Variant | Reuses | Set `$$widgetType` to |
| --------------- | ------------------- | --------------------- |
| `menu-select` | `connectMenu` / `useMenu` | `ais.menuSelect` |
| `range-input` | `connectRange` / `useRange` | `ais.rangeInput` |
| `range-slider` | `connectRange` | `ais.rangeSlider` |

For these, the audit will show `no` on connector/hook rows by design. Skip
connector/hook creation, import the upstream hook directly, and only port the
wrapper plus wrapper tests.
Comment on lines +70 to +72

## Workflow

1. Decide the scope.
- Existing connector, missing wrapper: keep the connector API unchanged and port only the wrapper plus wrapper tests.
- Variant widget (shared connector, different UI): skip connector/hook creation; reuse the existing hook and set a distinct `$$widgetType`.
- Missing connector or changed render state: start in `instantsearch.js`, then update every flavor and both common test suites.
- Vue port for a newer recommendation, chat, or filter-suggestions feature: inspect `Hits.js`, `Highlighter.js`, `DynamicWidgets.js`, and `util/vue-compat.js` before designing the wrapper.
2. Match a real precedent.
- Pick one close widget in the target flavor and one close widget in another flavor.
- Reuse the same prop names, slot or component escape hatches, `$$widgetType`, and test style.
3. Build from the bottom up.
- Connector exports belong in `packages/instantsearch.js/src/connectors/index.ts`.
- JS widget exports belong in `packages/instantsearch.js/src/widgets/index.ts`.
- React hook exports belong in `packages/react-instantsearch-core/src/index.ts`.
- React widget exports belong in `packages/react-instantsearch/src/widgets/index.ts`; `packages/react-instantsearch/src/index.ts` already re-exports widgets.
- Vue exports belong in `packages/vue-instantsearch/src/widgets.js`; `src/instantsearch.js` and the plugin re-export and register from there automatically.
4. Choose the right sharing model.
- JS and React: prefer `instantsearch-ui-components` when the markup can be shared.
- React: create `src/ui/<Pascal>.tsx` whenever the widget has no shared factory in `instantsearch-ui-components`. This includes simple widgets like `MenuSelect` (a plain `<select>`) — `src/ui/` is for all React-rendered markup, not only complex cases.
- Vue: use `.vue` SFCs for slot-heavy markup and `.js` render functions with `renderCompat` when reusing `instantsearch-ui-components`.
5. Wire tests before finishing.
1. **Scope the work.** Run `--gaps` to confirm what's missing, then pick:
- Missing wrapper, existing connector → keep the connector API unchanged and
port only the wrapper plus wrapper tests.
- Variant widget → skip connector/hook creation; reuse the upstream hook and
set a distinct `$$widgetType`.
- Missing connector or changed render state → start in `instantsearch.js`,
then update every flavor and both common test suites.
- Recommendation, chat, or filter-suggestions widget being ported to Vue →
read the [Vue render-function precedents](references/vue-flavor.md) before
designing the wrapper, since these all share the `Hits.js` pattern.
2. **Pick a precedent.**
- Open the [precedent picker](#precedent-picker) below and clone the closest
widget that already exists in the target flavor.
- Reuse the same prop names, slot or component escape hatches,
`$$widgetType`, and test style.
3. **Build from the bottom up.** Registration entry points:
- Connectors: `packages/instantsearch.js/src/connectors/index.ts`
- JS widgets: `packages/instantsearch.js/src/widgets/index.ts`
- React hooks: `packages/react-instantsearch-core/src/index.ts`
- React widgets: `packages/react-instantsearch/src/widgets/index.ts`
(`packages/react-instantsearch/src/index.ts` re-exports it for you)
- Vue widgets: `packages/vue-instantsearch/src/widgets.js`
(`src/instantsearch.js` and the plugin re-register from there)
4. **Choose the right sharing model.**
- JS and React: prefer `instantsearch-ui-components` when the markup can be
shared.
- React: create `src/ui/<Pascal>.tsx` whenever the widget has no shared
factory in `instantsearch-ui-components`. This includes simple widgets
like `MenuSelect` (a plain `<select>`) — `src/ui/` is for all
React-rendered markup, not only complex cases.
- Vue: use `.vue` SFCs for slot-heavy markup and `.js` render functions with
`renderCompat` when reusing `instantsearch-ui-components`.
5. **Wire tests before finishing.**
- Update `tests/common/widgets/<widget>/` whenever the wrapper behavior changes.
- Update `tests/common/connectors/<widget>/` whenever connector params or render state change.
- Register the suite in each flavor's `common-widgets.test.*` and `common-connectors.test.*`.
- Replace any `throw new Error('X is not supported in ...')` placeholder with real setup code in the target flavor's `common-widgets.test.*`.
- Update `tests/common/connectors/<widget>/` whenever connector params or
render state change.
- Register the suite in each flavor's `common-widgets.test.*` and
`common-connectors.test.*`.
- Replace any `throw new Error('X is not supported in ...')` placeholder
with real setup code. The audit prints the exact placeholder string —
watch for irregular names like `RelatedProduct` (singular) for
`related-products`.
- Remove the corresponding `skippedTests` entry in `testOptions` for that widget.
- For React: always add the widget to the switch in `packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx` with the required minimum props.
6. Check examples only when the widget is user-facing.
- Search existing examples first. Recommendation, chat, and query-suggestion widgets already live in getting-started or query-suggestions examples, not only the e-commerce apps.
- Add to `examples/*/e-commerce` only when the widget fits the shared storefront UX or existing Playwright coverage.
- For React: always add the widget to the switch in
`packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx`
with the required minimum props.
6. **Check examples only when the widget is user-facing.**
- Search existing examples first. Recommendation, chat, and query-suggestion
widgets already live in getting-started or query-suggestions examples,
not only the e-commerce apps.
- Add to `examples/*/e-commerce` only when the widget fits the shared
storefront UX or existing Playwright coverage.

## Precedent picker

| Porting target | Best precedents to clone from |
| --- | --- |
| JS widget, shared UI | `hits`, `related-products`, `trending-items`, `filter-suggestions` |
| JS widget, legacy templates + CSS helpers | `refinement-list`, `menu`, `pagination` |
| React widget with shared UI factory | `Hits.tsx`, `RelatedProducts.tsx`, `TrendingItems.tsx`, `FilterSuggestions.tsx` |
| React widget with React-only UI in `src/ui` | `SearchBox.tsx`, `RangeInput.tsx`, `RefinementList.tsx`, `Menu.tsx`, `SortBy.tsx`, `HitsPerPage.tsx` |
| React variant of another widget | `RangeInput.tsx` (uses `useRange`); for `MenuSelect` clone this pattern and use `useMenu` |
| Vue SFC, slot-heavy template | `RefinementList.vue`, `Menu.vue`, `Pagination.vue` |
| Vue render-function wrapper around shared UI | `Hits.js`, `Highlighter.js`, `DynamicWidgets.js`, `Feeds.js` |

## Reminders

- Keep `$$widgetType` aligned across flavors.
- Do not invent new Vue patterns; match `createWidgetMixin`, `createSuitMixin`, scoped slots, and `renderCompat`.
- Do not add memoization hooks in React unless an adjacent widget uses them for the same reason.
- Do not invent new Vue patterns; match `createWidgetMixin`, `createSuitMixin`,
scoped slots, and `renderCompat`.
- Do not add memoization hooks in React unless an adjacent widget uses them for
the same reason.
- `chat` is now available in UMD; no special exclusions apply.
- Don't trust grep for placeholder names — the audit script knows the irregular
ones (e.g. `RelatedProduct` vs `RelatedProducts`).

## References

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/port-widget/references/react-flavor.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ File: `packages/react-instantsearch-core/src/connectors/use<Pascal>.ts`
Choose the closest precedent before writing code:

- Shared UI component wrapper: `Hits.tsx`, `RelatedProducts.tsx`, `TrendingItems.tsx`, `FilterSuggestions.tsx`
- React-only presentational UI in `src/ui`: `SearchBox.tsx`, `RangeInput.tsx`, `RefinementList.tsx`, `Menu.tsx`, `MenuSelect.tsx`, `SortBy.tsx`
- React-only presentational UI in `src/ui`: `SearchBox.tsx`, `RangeInput.tsx`, `RefinementList.tsx`, `Menu.tsx`, `SortBy.tsx`, `HitsPerPage.tsx`

### If the UI is shared via `instantsearch-ui-components`

Expand Down
110 changes: 101 additions & 9 deletions .claude/skills/port-widget/references/vue-flavor.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Current patterns in this repo

- Slot-heavy SFCs: `RefinementList.vue`, `Menu.vue`, `Pagination.vue`
- Render-function wrappers around shared UI components: `Hits.js`, `Highlighter.js`
- Render-function wrappers around shared UI components: `Hits.js`, `Highlighter.js`, `DynamicWidgets.js`, `Feeds.js`
- Framework glue helpers: `mixins/widget.js`, `mixins/suit.js`, `util/vue-compat.js`

## Choose the wrapper shape deliberately
Expand All @@ -24,19 +24,111 @@

- Import connectors from `instantsearch.js/es/connectors/index`.
- Use `createWidgetMixin({ connector: ... }, { $$widgetType: 'ais.<camelName>' })`.
- Use `createSuitMixin({ name: '<Pascal>' })` for BEM classes. When the widget delegates all rendering to a shared `createXxxComponent` factory, the suit mixin's `suit()` method goes unused but the `classNames` prop it provides is still convenient. Pass `this.classNames` directly to the shared component's `classNames` prop (semantic keys like `{ root, container }`, not BEM keys).
- Use `createSuitMixin({ name: '<Pascal>' })` for BEM classes. When the widget
delegates all rendering to a shared `createXxxComponent` factory, the suit
mixin's `suit()` method goes unused but the `classNames` prop it provides is
still convenient. Pass `this.classNames` directly to the shared component's
`classNames` prop (semantic keys like `{ root, container }`, not BEM keys).
- Expose connector params through a computed `widgetParams()` object.
- When reusing shared UI factories, wrap the render function with `renderCompat(...)` and map `this.classNames` into the `classNames` prop expected by the shared component.
- Prefer `getScopedSlot` or `getDefaultSlot` helpers over direct slot access when matching render-function components.
- In render-function callbacks that reference connector state (e.g. `onSubmit`, `onInput`), read from `this.state.xxx` instead of destructured locals. Vue batches re-renders, so destructured values become stale between synchronous user interactions (type then click).
- When reusing shared UI factories, wrap the render function with
`renderCompat(...)` and map `this.classNames` into the `classNames` prop
expected by the shared component.
Comment on lines +27 to +35
- Prefer `getScopedSlot` or `getDefaultSlot` helpers over direct slot access
when matching render-function components.
- In render-function callbacks that reference connector state (e.g. `onSubmit`,
`onInput`), read from `this.state.xxx` instead of destructured locals. Vue
batches re-renders, so destructured values become stale between synchronous
user interactions (type then click).

## Annotated template — render function around a shared UI factory

This is the pattern to use for recommendation widgets (`RelatedProducts`,
`TrendingItems`, `TrendingFacets`, `FrequentlyBoughtTogether`, `LookingSimilar`)
and other widgets that reuse a `createXxxComponent` factory from
`instantsearch-ui-components`. Distilled from `Hits.js` and `Feeds.js`:

```js
import { createXxxComponent } from 'instantsearch-ui-components';
import { connectXxx } from 'instantsearch.js/es/connectors/index';

import { createSuitMixin } from '../mixins/suit';
import { createWidgetMixin } from '../mixins/widget';
import { getScopedSlot, renderCompat } from '../util/vue-compat';

export default {
name: 'AisXxx',
mixins: [
createWidgetMixin(
{ connector: connectXxx },
{ $$widgetType: 'ais.xxx' }, // keep aligned with JS and React
),
createSuitMixin({ name: 'Xxx' }),
],
props: {
// 1:1 with connector params — keep names matching the React widget's props.
limit: { type: Number, default: undefined },
transformItems: { type: Function, default: undefined },
// ...
},
computed: {
widgetParams() {
return {
limit: this.limit,
transformItems: this.transformItems,
// ...
};
},
},
render: renderCompat(function (h) {
if (!this.state) {
return null; // connector hasn't delivered state yet
}

// Map Vue scoped slots onto the shared UI factory's component props.
const itemSlot = getScopedSlot(this, 'item');
const headerSlot = getScopedSlot(this, 'header');

return h(createXxxComponent({ createElement: h }), {
items: this.state.items,
sendEvent: this.state.sendEvent,
itemComponent: itemSlot,
headerComponent: headerSlot,
classNames: this.classNames && {
root: this.classNames['ais-Xxx'],
list: this.classNames['ais-Xxx-list'],
item: this.classNames['ais-Xxx-item'],
// ...
},
});
}),
};
```

When the matching React widget uses `useInstantSearch().status` (most
recommendation widgets do), expose it through Vue's `state` too — the connector
already does this for you via `createWidgetMixin`.

## Registration checklist

- Export the component from `packages/vue-instantsearch/src/widgets.js`.
- Do not manually edit `src/plugin.js` or `src/instantsearch.js` for normal widget additions; they already derive registration and exports from `widgets.js`.
- Search `packages/vue-instantsearch/src/__tests__/common-widgets.test.js` and `common-connectors.test.js` for placeholder "not supported" branches before deciding the widget is genuinely absent.
- Do not manually edit `src/plugin.js` or `src/instantsearch.js` for normal
widget additions; they already derive registration and exports from
`widgets.js`.
- Search `packages/vue-instantsearch/src/__tests__/common-widgets.test.js` and
`common-connectors.test.js` for placeholder "not supported" branches before
deciding the widget is genuinely absent. The audit script
(`--gaps` mode) lists which widgets still have placeholders.

## Missing-feature caution

- Recommendation widgets, chat, and filter suggestions already have connector-level test placeholders in Vue, but several still throw explicit "not supported" errors at the widget layer.
- If you port one of those widgets, remove the placeholder failure and replace it with real setup code plus the component implementation.
- Recommendation widgets, chat, and filter suggestions already have
connector-level test placeholders in Vue, but most still throw explicit
"not supported" errors at the widget layer.
- The placeholder name does not always match `PascalCase(widget)` — for
example `related-products` uses `RelatedProduct` (singular). The audit
prints the exact string.
- When porting one of those widgets, remove the placeholder failure and
replace it with real setup code plus the component implementation.
- For async connectors like `connectChat`, follow the Vue test timing notes
in [testing.md](./testing.md) — initial state arrives after a macrotask,
so the test setup needs both `nextTick()` and a `setTimeout(0)` wait.
Loading
Loading