From 7ef238f93a8a4f455cc7db676e7c2ae5ea87b111 Mon Sep 17 00:00:00 2001 From: Aymeric Giraudet Date: Mon, 11 May 2026 11:02:54 +0200 Subject: [PATCH 1/2] chore(skill): improve port-widget audit accuracy and add gaps mode The audit script previously missed widgets like `dynamic-widgets` (uses `.ts`, not `.tsx`) and falsely flagged variants like `range-input`/`menu-select` as missing their connector/hook. It also looked for Vue placeholders by exact PascalCase match, missing irregular names like `RelatedProduct` (singular). Adds a `--gaps` mode so contributors can see all open porting work across flavors at a glance, and refreshes the skill docs around an audit-first workflow with a Vue render-function template for the recommendation/chat family of widgets. --- .claude/skills/port-widget/SKILL.md | 153 ++++++-- .../port-widget/references/react-flavor.md | 2 +- .../port-widget/references/vue-flavor.md | 110 +++++- .../scripts/audit_widget_coverage.py | 367 +++++++++++++----- 4 files changed, 494 insertions(+), 138 deletions(-) diff --git a/.claude/skills/port-widget/SKILL.md b/.claude/skills/port-widget/SKILL.md index 2be2d4ff3a..0fcdece000 100644 --- a/.claude/skills/port-widget/SKILL.md +++ b/.claude/skills/port-widget/SKILL.md @@ -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 ` 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 +``` + +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//`. Low + priority — add only when changing the connector contract. ## Layer map - Connector: `packages/instantsearch.js/src/connectors//connect.ts` -- JS widget: `packages/instantsearch.js/src/widgets//.tsx` +- JS widget: `packages/instantsearch.js/src/widgets//.tsx` (or `.ts` for `dynamic-widgets`) - React hook: `packages/react-instantsearch-core/src/connectors/use.ts` - React widget: `packages/react-instantsearch/src/widgets/.tsx` - Optional React UI: `packages/react-instantsearch/src/ui/.tsx` @@ -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. ## 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/.tsx` whenever the widget has no shared factory in `instantsearch-ui-components`. This includes simple widgets like `MenuSelect` (a plain ``) — `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//` whenever the wrapper behavior changes. - - Update `tests/common/connectors//` 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//` 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 diff --git a/.claude/skills/port-widget/references/react-flavor.md b/.claude/skills/port-widget/references/react-flavor.md index 87d218b119..a6676f58e7 100644 --- a/.claude/skills/port-widget/references/react-flavor.md +++ b/.claude/skills/port-widget/references/react-flavor.md @@ -19,7 +19,7 @@ File: `packages/react-instantsearch-core/src/connectors/use.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` diff --git a/.claude/skills/port-widget/references/vue-flavor.md b/.claude/skills/port-widget/references/vue-flavor.md index a2f93d186e..6fdc6974bc 100644 --- a/.claude/skills/port-widget/references/vue-flavor.md +++ b/.claude/skills/port-widget/references/vue-flavor.md @@ -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 @@ -24,19 +24,111 @@ - Import connectors from `instantsearch.js/es/connectors/index`. - Use `createWidgetMixin({ connector: ... }, { $$widgetType: 'ais.' })`. -- Use `createSuitMixin({ name: '' })` 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: '' })` 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. +- 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. diff --git a/.claude/skills/port-widget/scripts/audit_widget_coverage.py b/.claude/skills/port-widget/scripts/audit_widget_coverage.py index 273e2beb0d..3f85bcd593 100755 --- a/.claude/skills/port-widget/scripts/audit_widget_coverage.py +++ b/.claude/skills/port-widget/scripts/audit_widget_coverage.py @@ -1,18 +1,65 @@ #!/usr/bin/env python3 +"""Audit InstantSearch widget coverage across JavaScript, React, and Vue. + +Usage: + audit_widget_coverage.py [ ...] + audit_widget_coverage.py --all + audit_widget_coverage.py --gaps # only widgets with missing artifacts +""" + from __future__ import annotations import argparse +import re import subprocess import sys from pathlib import Path from typing import Iterable +# Widgets that reuse another widget's connector/hook. +# A variant's connector and hook entries are expected to be absent. +VARIANTS: dict[str, str] = { + "menu-select": "menu", # uses connectMenu / useMenu + "range-input": "range", # uses connectRange / useRange + "range-slider": "range", # JS-only widget that uses connectRange +} + +# Widgets that don't follow the normal widget layout. Skipped by --gaps. +SPECIAL: dict[str, str] = { + "instantsearch": ( + "Root provider widget. Lives in core packages and bootstraps the app — " + "not a normal widget to port." + ), + "dynamic-widgets": ( + "React component lives in `react-instantsearch-core/src/components/DynamicWidgets.tsx`, " + "not in `react-instantsearch/src/widgets/`." + ), +} + +# Vue placeholder names that don't match `PascalCase(widget)`. +# Map widget kebab name -> placeholder string used in +# `packages/vue-instantsearch/src/__tests__/common-widgets.test.js`. +VUE_WIDGET_PLACEHOLDER_NAMES: dict[str, str] = { + "related-products": "RelatedProduct", # singular in the placeholder +} + +# Vue widgets known to ship as `.js` render-function wrappers around a shared +# UI factory rather than `.vue` SFCs. Useful as precedents when porting newer +# recommendation/chat widgets. +VUE_RENDER_FUNCTION_PRECEDENTS = ("Hits.js", "Highlighter.js", "DynamicWidgets.js", "Feeds.js") + + def pascal_case(widget: str) -> str: return "".join(part.capitalize() for part in widget.split("-")) +def camel_case(widget: str) -> str: + pascal = pascal_case(widget) + return pascal[0].lower() + pascal[1:] + + def detect_repo_root(start: Path, explicit_repo: str | None) -> Path: if explicit_repo: root = Path(explicit_repo).expanduser().resolve() @@ -58,6 +105,13 @@ def file_contains(path: Path, needle: str) -> bool: return needle in path.read_text() +def file_matches(path: Path, pattern: re.Pattern[str]) -> bool: + if not path.exists(): + return False + + return bool(pattern.search(path.read_text())) + + def rel(root: Path, value: str) -> str: if " | " in value: return " | ".join(rel(root, item) for item in value.split(" | ")) @@ -69,84 +123,58 @@ def rel(root: Path, value: str) -> str: return value +def js_widget_paths(root: Path, widget: str) -> list[Path]: + """JS widgets can be either .tsx (most) or .ts (dynamic-widgets).""" + base = root / "packages" / "instantsearch.js" / "src" / "widgets" / widget + return [base / f"{widget}.tsx", base / f"{widget}.ts"] + + +def vue_component_paths(root: Path, widget: str) -> list[Path]: + base = root / "packages" / "vue-instantsearch" / "src" / "components" + pascal = pascal_case(widget) + return [base / f"{pascal}.vue", base / f"{pascal}.js"] + + def build_rows( root: Path, widget: str ) -> tuple[str, list[tuple[str, bool, str]], list[str]]: pascal = pascal_case(widget) - camel = pascal[0].lower() + pascal[1:] + camel = camel_case(widget) + variant_of = VARIANTS.get(widget) + + if variant_of: + connector_owner = variant_of + hook_owner = variant_of + else: + connector_owner = widget + hook_owner = widget - vue_component_paths = [ + connector_pascal = pascal_case(connector_owner) + connector_path = ( root / "packages" - / "vue-instantsearch" + / "instantsearch.js" / "src" - / "components" - / f"{pascal}.vue", - root / "packages" / "vue-instantsearch" / "src" / "components" / f"{pascal}.js", - ] + / "connectors" + / connector_owner + / f"connect{connector_pascal}.ts" + ) + hook_path = ( + root + / "packages" + / "react-instantsearch-core" + / "src" + / "connectors" + / f"use{connector_pascal}.ts" + ) + + js_present, js_path = exists_any(js_widget_paths(root, widget)) + vue_present, vue_path = exists_any(vue_component_paths(root, widget)) rows: list[tuple[str, bool, str]] = [ - ( - "connector", - ( - root - / "packages" - / "instantsearch.js" - / "src" - / "connectors" - / widget - / f"connect{pascal}.ts" - ).exists(), - str( - root - / "packages" - / "instantsearch.js" - / "src" - / "connectors" - / widget - / f"connect{pascal}.ts" - ), - ), - ( - "js widget", - ( - root - / "packages" - / "instantsearch.js" - / "src" - / "widgets" - / widget - / f"{widget}.tsx" - ).exists(), - str( - root - / "packages" - / "instantsearch.js" - / "src" - / "widgets" - / widget - / f"{widget}.tsx" - ), - ), - ( - "react hook", - ( - root - / "packages" - / "react-instantsearch-core" - / "src" - / "connectors" - / f"use{pascal}.ts" - ).exists(), - str( - root - / "packages" - / "react-instantsearch-core" - / "src" - / "connectors" - / f"use{pascal}.ts" - ), - ), + ("connector", connector_path.exists(), str(connector_path)), + ("js widget", js_present, js_path), + ("react hook", hook_path.exists(), str(hook_path)), ( "react widget", ( @@ -185,7 +213,7 @@ def build_rows( / f"{pascal}.tsx" ), ), - ("vue component", *exists_any(vue_component_paths)), + ("vue component", vue_present, vue_path), ( "common widget tests", (root / "tests" / "common" / "widgets" / widget / "index.ts").exists(), @@ -199,13 +227,8 @@ def build_rows( ( "js connector export", file_contains( - root - / "packages" - / "instantsearch.js" - / "src" - / "connectors" - / "index.ts", - f"connect{pascal}", + root / "packages" / "instantsearch.js" / "src" / "connectors" / "index.ts", + f"connect{connector_pascal}", ), "packages/instantsearch.js/src/connectors/index.ts", ), @@ -221,20 +244,15 @@ def build_rows( "react core export", file_contains( root / "packages" / "react-instantsearch-core" / "src" / "index.ts", - f"./connectors/use{pascal}", + f"./connectors/use{connector_pascal}", ), "packages/react-instantsearch-core/src/index.ts", ), ( "react widget export", - file_contains( - root - / "packages" - / "react-instantsearch" - / "src" - / "widgets" - / "index.ts", - f"./{pascal}", + file_matches( + root / "packages" / "react-instantsearch" / "src" / "widgets" / "index.ts", + re.compile(rf"['\"]\./{pascal}['\"]"), ), "packages/react-instantsearch/src/widgets/index.ts", ), @@ -249,6 +267,17 @@ def build_rows( ] notes: list[str] = [] + + if variant_of: + notes.append( + f"Variant of `{variant_of}`: reuses `connect{connector_pascal}` and " + f"`use{connector_pascal}`. Skip connector/hook creation and set " + f"`$$widgetType: 'ais.{camel}'`." + ) + + if widget in SPECIAL: + notes.append(f"Special widget: {SPECIAL[widget]}") + vue_widget_tests = ( root / "packages" @@ -257,9 +286,13 @@ def build_rows( / "__tests__" / "common-widgets.test.js" ) - unsupported_text = f"{pascal} is not supported in Vue InstantSearch" - if file_contains(vue_widget_tests, unsupported_text): - notes.append("Vue common widget tests still mark this widget as unsupported.") + placeholder_name = VUE_WIDGET_PLACEHOLDER_NAMES.get(widget, pascal) + placeholder_text = f"{placeholder_name} is not supported in Vue InstantSearch" + if file_contains(vue_widget_tests, placeholder_text): + notes.append( + f"Vue common widget tests still throw `\"{placeholder_text}\"`. " + "Replace the placeholder with real setup code." + ) vue_connector_tests = ( root @@ -269,9 +302,10 @@ def build_rows( / "__tests__" / "common-connectors.test.js" ) - if file_contains(vue_connector_tests, f"create{pascal}ConnectorTests: () => {{}}"): + connector_placeholder = f"create{pascal}ConnectorTests: () => {{}}" + if file_contains(vue_connector_tests, connector_placeholder): notes.append( - "Vue common connector tests still use a placeholder setup for this connector." + f"Vue common connector tests still stub `{connector_placeholder}`." ) if widget == "autocomplete": @@ -282,12 +316,15 @@ def build_rows( "related-products", "frequently-bought-together", "trending-items", + "trending-facets", "looking-similar", "filter-suggestions", "chat", }: notes.append( - "Check getting-started and query-suggestions examples before adding this widget to the e-commerce apps." + "Recommendation/chat family — when porting to Vue, follow the " + "`Hits.js` render-function precedent rather than a `.vue` SFC. " + "Check getting-started and query-suggestions examples before adding to e-commerce apps." ) return camel, rows, notes @@ -308,6 +345,141 @@ def print_report(root: Path, widget: str) -> None: print(f" - {note}") +def gap_summary(root: Path, widget: str) -> dict | None: + """Return a dict describing this widget's gaps, or None if fully covered. + + Suppresses gaps that are expected: variant widgets (connector/hook live + elsewhere), special widgets that don't follow the layout, and missing + `react ui` files (only required for some widgets, not all). + """ + if widget in SPECIAL: + return None + + _, rows, notes = build_rows(root, widget) + row_map = {label: present for label, present, _ in rows} + is_variant = widget in VARIANTS + + # Ignored rows: optional or layout-dependent + optional_rows = {"react ui"} + + # Variants intentionally skip these rows + if is_variant: + optional_rows |= { + "connector", + "react hook", + "common connector tests", + "js connector export", + "react core export", + } + + missing_flavors = {} + if not row_map["js widget"] or not row_map["js widget export"]: + missing_flavors["js"] = [] + if not row_map["js widget"]: + missing_flavors["js"].append("widget file") + if not row_map["js widget export"]: + missing_flavors["js"].append("widget export") + if not is_variant and not row_map["connector"]: + missing_flavors["js"].append("connector file") + if not is_variant and not row_map["js connector export"]: + missing_flavors["js"].append("connector export") + + react_missing = [] + if not is_variant and not row_map["react hook"]: + react_missing.append("hook") + if not is_variant and not row_map["react core export"]: + react_missing.append("hook export") + if not row_map["react widget"]: + react_missing.append("widget") + if not row_map["react widget export"]: + react_missing.append("widget export") + if react_missing: + missing_flavors["react"] = react_missing + + vue_missing = [] + if not row_map["vue component"]: + vue_missing.append("component") + if not row_map["vue widget export"]: + vue_missing.append("export") + if any("common widget tests still throw" in note for note in notes): + vue_missing.append("widget test placeholder") + if any("common connector tests still stub" in note for note in notes): + vue_missing.append("connector test placeholder") + if vue_missing: + missing_flavors["vue"] = vue_missing + + common_tests_missing = [] + if not row_map["common widget tests"]: + common_tests_missing.append("widget suite") + if "common connector tests" not in optional_rows and not row_map["common connector tests"]: + common_tests_missing.append("connector suite") + if common_tests_missing: + missing_flavors["tests/common"] = common_tests_missing + + if not missing_flavors: + return None + + return { + "widget": widget, + "variant_of": VARIANTS.get(widget), + "missing": missing_flavors, + "notes": notes, + } + + +def print_gaps(root: Path, widgets: list[str]) -> None: + summaries = [g for g in (gap_summary(root, w) for w in widgets) if g] + + if not summaries: + print("No gaps found — every audited widget is fully covered.") + return + + # A widget can have implementation gaps (js/react/vue) and/or test-suite + # gaps (tests/common). Sort into porting work vs test-only follow-ups. + react_only = [] + vue_only = [] + js_only = [] + multi_impl = [] + tests_only = [] + for s in summaries: + impl_flavors = set(s["missing"].keys()) - {"tests/common"} + if not impl_flavors: + tests_only.append(s) + continue + if impl_flavors == {"react"}: + react_only.append(s) + elif impl_flavors == {"vue"}: + vue_only.append(s) + elif impl_flavors == {"js"}: + js_only.append(s) + else: + multi_impl.append(s) + + def section(title: str, group: list[dict]) -> None: + if not group: + return + header = f"{title} ({len(group)})" + print(f"\n{header}") + print("-" * len(header)) + for s in group: + variant = f" [variant of {s['variant_of']}]" if s["variant_of"] else "" + print(f" {s['widget']}{variant}") + for flavor, items in s["missing"].items(): + print(f" {flavor}: {', '.join(items)}") + + impl_count = len(react_only) + len(vue_only) + len(js_only) + len(multi_impl) + print(f"Gaps found in {len(summaries)} widget(s) ({impl_count} need porting work).") + section("React gaps", react_only) + section("Vue gaps", vue_only) + section("JS gaps", js_only) + section("Multi-flavor gaps", multi_impl) + section("Test-suite-only gaps (low priority)", tests_only) + + print("\nNext steps:") + print(" - Run `audit_widget_coverage.py ` for the full per-widget report.") + print(" - Open `.claude/skills/port-widget/SKILL.md` for the porting workflow.") + + def discover_widgets(root: Path) -> list[str]: widgets_dir = root / "tests" / "common" / "widgets" return sorted(path.name for path in widgets_dir.iterdir() if path.is_dir()) @@ -326,6 +498,11 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Audit every widget that has a shared widget test folder", ) + parser.add_argument( + "--gaps", + action="store_true", + help="Only report widgets with missing artifacts. Implies --all when no widgets are given.", + ) return parser.parse_args() @@ -334,11 +511,15 @@ def main() -> int: root = detect_repo_root(Path.cwd(), args.repo) widgets = args.widgets - if args.all: + if args.all or (args.gaps and not widgets): widgets = discover_widgets(root) if not widgets: - raise SystemExit("Pass a widget name or use --all.") + raise SystemExit("Pass a widget name, --all, or --gaps.") + + if args.gaps: + print_gaps(root, widgets) + return 0 for index, widget in enumerate(widgets): if index: From 7b8ec4ee4df3668294a81fcb115bafedc9fc370f Mon Sep 17 00:00:00 2001 From: Aymeric Giraudet Date: Tue, 12 May 2026 13:19:08 +0200 Subject: [PATCH 2/2] chore(skill): document Vue 2 pitfalls discovered while porting widgets Adds a "Pitfalls discovered while porting recommendation widgets" section to the Vue reference, covering the Fragment shim, status tracking via `createRecommendMixin`, async state delivery in test setups, Vue's swallowed connector throws, and flavored test suites that need real Vue widgetParams. Captured while implementing the actual ports in a separate PR; surfacing the lessons here so the next contributor doesn't have to rediscover them. --- .../port-widget/references/vue-flavor.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/.claude/skills/port-widget/references/vue-flavor.md b/.claude/skills/port-widget/references/vue-flavor.md index 6fdc6974bc..8f9738e392 100644 --- a/.claude/skills/port-widget/references/vue-flavor.md +++ b/.claude/skills/port-widget/references/vue-flavor.md @@ -132,3 +132,87 @@ already does this for you via `createWidgetMixin`. - 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. + +## Pitfalls discovered while porting recommendation widgets + +### Vue 2 needs help with shared JSX factories + +The `createXxxComponent` factories in `instantsearch-ui-components` use +React-style JSX (``, `onClick={...}`). Vue 2 has no native fragment +and Vue 2's `createElement` ignores `onClick`-style props (they fall through +to HTML attributes). The augmented `renderCompat` `h` in +`util/vue-compat/index-vue2.js` now normalizes both — but only for the +augmented path. When you import shared factories: + +```js +import { Fragment, renderCompat } from '../util/vue-compat'; +// ... +return h(createXxxComponent({ createElement: h, Fragment }), props); +``` + +Always pass `Fragment`. Without it, the default `EmptyComponent` / +`DefaultItem` from `recommend-shared/` crash in Vue 2. + +### Tracking `status` reactively + +The shared Recommend components need a `status` prop to decide between +"render results" and "render empty state." React reads it from +`useInstantSearch()`; Vue must subscribe to the InstantSearch lifecycle. + +Use the shared mixin instead of re-implementing per widget: + +```js +import { createRecommendMixin } from '../mixins/recommend'; +// ... +mixins: [ + createWidgetMixin({ connector: connectXxx }, { $$widgetType: '...' }), + createSuitMixin({ name: 'Xxx' }), + createRecommendMixin(), +], +// then in render: `status: this.status` +``` + +`createRecommendMixin` is defensive: if the surrounding test or harness +provides a stub InstantSearch instance without `addListener`, it skips +subscribing and falls back to `'idle'`. + +### Async state delivery in test setup + +Recommend connectors deliver state after the Recommend API resolves. The +Vue setup function must flush both the macrotask queue and Vue's update +queue, in that order: + +```js +mountApp({ render: ... }, container); +await nextTick(); +await new Promise((resolve) => setTimeout(resolve, 0)); +await nextTick(); +``` + +### Vue swallows connector throws + +Some common tests assert that the widget throws on missing required +options (e.g. `agentId`). Vue's `created` hook logs the error via +`[Vue warn]` instead of letting it propagate. Skip these tests for Vue +via `skippableTest` rather than fighting the framework: + +```js +createXxxWidgetTests: { + skippedTests: { + 'throws without agentId': true, + }, +}, +``` + +For this to work, the test source file must wrap the test in +`skippableTest(name, skippedTests, fn)` instead of plain `test(name, fn)`. +Same for whole describe blocks — use `skippableDescribe`. + +### Flavored test suites need real Vue params + +When a widget's test index declares `flavored = true` (e.g. FilterSuggestions, +Chat), `runTestSuites` extracts `widgetParams[flavor]`. If `vue: +Record` was used as a placeholder, the Vue setup receives an +empty object and the connector will throw on missing required params. Update +the type to a real connector params shape and fill in real values in every +test case before wiring up the Vue setup.