diff --git a/AGENTS.md b/AGENTS.md
index 8c9e498..bbaa462 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -97,9 +97,19 @@ There is no Vapor CI gate until Vue 3.6 is stable; the constraint is upheld by r
**not** safe next to a `{{ }}` interpolation, which condenses to a single space instead. Render
every label through `QueryBuilderLabel` (a component, so it counts as an element) rather than
interpolating.
-- Every default control sets `inheritAttrs: false`. `Rule`/`RuleGroup` hand each subcomponent a
- common prop bag (`rule`, `rules`, `ruleOrGroup`, `fieldData`, ...) that most of them do not
- declare; without this those land on the DOM as stray attributes React never emits.
+- No default control sets `inheritAttrs: false`; attribute fallthrough is on, as Vue developers
+ expect. Two invariants keep stray attributes off the DOM, and both are gated against core's
+ `controlPropKeys` — at runtime by `src/components/controlProps.test.ts`, at compile time by
+ `src/types/types.test-d.ts`:
+ 1. every default control **declares** every prop its control keys receive (`ValueSelector`
+ therefore declares `VersatileSelectorProps`, the union across the five selector keys it is
+ the default for), and
+ 2. `Rule`/`RuleGroup`/`RuleComponents`/`RuleGroupHeader`/`RuleGroupBody` **pass** nothing
+ beyond those keys — which is why `rule` is bound per-control rather than folded into the
+ `common` bag.
+ A new control prop must be added to both sides.
+- Bulk-override membership (`actionElement`, `valueSelector`) comes from core's `controlKind`,
+ never from sniffing the key name. `shiftActions`/`undoRedoActions` are not bulk targets.
### Slots
@@ -126,7 +136,7 @@ loader and guards it; it runs as part of `check:exports`.
- The query is a `shallowRef`. A deep proxy defeats reference comparisons and is rejected by the manager's Immer deep-freeze.
- Always `toRaw()` a query before handing it to the manager.
-- Likewise `toRaw()` the **manager itself** before calling it. `QueryManager` keeps its history in private class fields, which a reactive `Proxy` cannot read through (`Cannot read private member #past`). `schema` is an ordinary computed value in normal use, but Vue Test Utils wraps mount props in `reactive`, and nothing stops a consumer from doing the same.
+- Do **not** `toRaw()` the manager. As of `@react-querybuilder/core` 8.23.0 `QueryManager` keeps its state in a single non-enumerable, symbol-keyed own property that forwards through a `Proxy`'s `get` trap, and that property carries `__v_skip`, so `reactive()` neither breaks it nor deep-proxies its internals. This matters because Vue Test Utils wraps mount props in `reactive`, so a proxied `schema.manager` arrives by accident. Before 8.23.0 the state was in `#private` fields and every call threw `Cannot read private member #past`; `useQueryBuilder.test.ts` covers the proxy cycle.
- A `useRule`/`useRuleGroup` return object reaches the internal components through **provide/inject**, not as a prop — see `src/internal/parts.ts`. It is unwrapped with `reactive()` exactly once, at the provider: Vue auto-unwraps refs only for top-level `setup` bindings, and `reactive()` on a container of refs yields each `.value` directly while leaving plain handler functions alone. Consumers inject and destructure; none of them calls `reactive()` itself.
- `RuleSubQuery` provides the subquery's group under **both** the subquery key and the group key, so the `RuleGroupHeader`/`RuleGroupBody` that `RuleComponents` renders resolve the subquery's group rather than the enclosing one. `Rule` correspondingly shadows the subquery key with `undefined`, so a rule nested inside a subquery is not mistaken for one.
- The **public** injection accessors (`useSchema`, `useQueryBuilderActions`, `useCurrentRule`, `useCurrentRuleGroup`, `useCurrentPath`) use their own keys in `src/composables/accessors.ts`. Keep them separate from the internal keys: the internal shape is not public API. `Rule`/`RuleGroup` re-provide schema and actions at every level so a subquery's descendants see the subquery's own.
@@ -186,6 +196,15 @@ specifiers, `exports`-map targets and condition order, built-artifact module-cyc
`test:coverage` (three thresholds), `conformance` (DOM parity, 232 tests), `test:ssr`, and the
a11y suite (`src/components/a11y.test.ts`, part of the default run).
+The fallthrough gate (`src/components/controlProps.test.ts`) is proven red two ways: fold `rule`
+back into `RuleComponents`' `common` bag (the "passes nothing extra" half), or narrow
+`ValueSelector` back to `ValueSelectorProps` (the "declares everything" half). Its compile-time
+twin in `src/types/types.test-d.ts` is proven red by deleting any prop from a control's props
+interface.
+
+The proxy-safe-manager gate (`useQueryBuilder.test.ts`, "drives a manager wrapped in
+`reactive()`") is proven red by pinning `@react-querybuilder/core` below 8.23.0.
+
The a11y gate is proven red by removing the `title` binding from `ValueSelector.vue`, which turns
all eight axe cases red.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87ff51f..66b2004 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **`Label` is renamed to `QueryBuilderLabel`** (and `LabelProps` to `QueryBuilderLabelProps`).
**Breaking, with no deprecated alias.** `Label` is far too generic for a top-level export and
a likely collision in any globally registered setup.
+- **`controlKeys` is no longer exported from this package.** **Breaking in name only:** the
+ package re-exports `@react-querybuilder/core`, whose 8.23.0 `controlKeys` takes over the name.
+ Core's list is a superset — it includes the three controls this port does not implement
+ (`dragHandle`, `ruleGroupHeaderElements`, `ruleGroupBodyElements`).
- **`RuleComponents`, `RuleGroupHeader`, `RuleGroupBody`, and `RuleSubQuery` are no longer
exported**, and their prop types are gone with them. **Breaking.** The docs always described
them as internal; they now read everything they render through injection and cannot be
@@ -35,6 +39,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Changed
+- **Minimum `@react-querybuilder/core` is 8.23.0.** That release makes `QueryManager` readable
+ through a `Proxy` and adds the `controlKeys`/`controlPropKeys`/`controlKind` data this package
+ now builds on. (Until 8.23.0 ships, the dependency points at a pkg.pr.new pre-release build.)
+- **A `QueryManager` may be wrapped in `reactive()`.** The `toRaw(manager)` calls are gone from
+ `useQueryBuilder` and `UndoRedoActions`; the manager's state now reads correctly through a
+ proxy, and `reactive()` will not deep-proxy its internals. Vue Test Utils wraps mount props in
+ `reactive`, so this footgun was hit by accident rather than by choice. `toRaw()` on the
+ **query** is unchanged — Immer's deep-freeze is a separate concern.
+- **Attribute fallthrough is enabled on every default control.** `inheritAttrs: false` is gone
+ from all nine, so a consumer-supplied `class`, `id`, or listener lands on the rendered element
+ the way a Vue developer expects. Nothing strays there: `ValueSelector` now declares
+ `VersatileSelectorProps` (the union of the five selector control prop sets it is the default
+ for), and `Rule`/`RuleGroup` pass exactly the keys core's `controlPropKeys` lists — `rule` is
+ bound only on the controls that actually take it, rather than on every subcomponent. A runtime
+ test and a compile-time test gate both halves against core.
+- **`shiftActions` and `undoRedoActions` are no longer targets of the `actionElement` bulk
+ override.** **Breaking, if you relied on it.** Membership now comes from core's `controlKind`
+ instead of a `key.endsWith('Action'/'Actions')` test, which matches React. Both controls still
+ render their buttons through the `actionElement` control, so an override reaches them that way.
- Structural manager options are now reactive. `fields`, `operators`, `combinators`,
`baseField`/`baseOperator`/`baseCombinator`, `translations`, `maxLevels`, `disabled`, the
`autoSelect*`/`resetOn*`/`listsAsArrays`/`addRuleToNewGroups` flags, `validator`, and
diff --git a/bun.lock b/bun.lock
index b2a1ec5..ae2a823 100644
--- a/bun.lock
+++ b/bun.lock
@@ -54,7 +54,7 @@
"name": "@react-querybuilder/vue",
"version": "0.2.0",
"dependencies": {
- "@react-querybuilder/core": "^8.22.3",
+ "@react-querybuilder/core": "https://pkg.pr.new/react-querybuilder/react-querybuilder/@react-querybuilder/core@a784003",
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
@@ -397,7 +397,7 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
- "@react-querybuilder/core": ["@react-querybuilder/core@8.22.3", "", { "dependencies": { "@ts-jison/lexer": "0.4.1-alpha.1", "@ts-jison/parser": "0.4.1-alpha.1", "immer": "^11.1.15", "numeric-quantity": "^3.2.2" }, "peerDependencies": { "@tanstack/db": ">=0.6.9", "@traqula/parser-sparql-1-2": ">=0.1.0", "chevrotain": ">=11", "drizzle-orm": ">=0.38.0", "json-logic-js": ">=2", "jsonata": ">=2", "sequelize": ">=6", "spel2js": ">=0.2.0" }, "optionalPeers": ["@tanstack/db", "@traqula/parser-sparql-1-2", "chevrotain", "drizzle-orm", "json-logic-js", "jsonata", "sequelize", "spel2js"] }, "sha512-/0sJI40ByEb2N+u5Y6PfrOMHN2usmAtFAqay1WALKolCMhurV+z/ZZ5ANAOTcwBTyATvga0sdEYdhudZO5WEvA=="],
+ "@react-querybuilder/core": ["@react-querybuilder/core@https://pkg.pr.new/react-querybuilder/react-querybuilder/@react-querybuilder/core@a784003", { "dependencies": { "@ts-jison/lexer": "0.4.1-alpha.1", "@ts-jison/parser": "0.4.1-alpha.1", "@types/json-logic-js": ">=2", "immer": "^11.1.16", "numeric-quantity": "^3.2.2" }, "peerDependencies": { "@tanstack/db": ">=0.6.9", "@traqula/parser-sparql-1-2": ">=0.1.0", "chevrotain": ">=11", "drizzle-orm": ">=0.38.0", "json-logic-js": ">=2", "jsonata": ">=2", "sequelize": ">=6", "spel2js": ">=0.2.0" }, "optionalPeers": ["@tanstack/db", "@traqula/parser-sparql-1-2", "chevrotain", "drizzle-orm", "json-logic-js", "jsonata", "sequelize", "spel2js"] }, "sha512-WHxhs4DFfM6xC9PrnDckgWOAT9NlRHHpinq5AZ9Z6gzmVa4pY1mlPLIo0I/oF+cmYDEcip1q6JUG7XL2dN/Fiw=="],
"@react-querybuilder/vue": ["@react-querybuilder/vue@workspace:packages/vue-querybuilder"],
@@ -535,6 +535,8 @@
"@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="],
+ "@types/json-logic-js": ["@types/json-logic-js@2.0.8", "", {}, "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q=="],
+
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
diff --git a/docs/customization.md b/docs/customization.md
index caee6ed..5de7f19 100644
--- a/docs/customization.md
+++ b/docs/customization.md
@@ -180,7 +180,6 @@ package barrel:
@@ -195,8 +194,11 @@ const props = defineProps();
```
-Set `inheritAttrs: false`. `Rule` and `RuleGroup` hand every subcomponent a common prop bag, and
-anything a replacement does not declare would otherwise land on the DOM as a stray attribute.
+Declaring the full props type is enough: `Rule` and `RuleGroup` pass exactly the props each
+control's type lists, so nothing is left over to fall through, and normal Vue attribute
+fallthrough stays available for whatever a consumer of _your_ component passes. If you declare
+only a subset of the props, set `inheritAttrs: false` so the rest do not land on the DOM as
+stray attributes.
Keep `data-testid`, `class`, and `title` if you want the standard stylesheets — and any tests
written against the standard DOM — to keep working.
diff --git a/docs/differences-from-react-querybuilder.md b/docs/differences-from-react-querybuilder.md
index 4b95512..bc4b480 100644
--- a/docs/differences-from-react-querybuilder.md
+++ b/docs/differences-from-react-querybuilder.md
@@ -76,8 +76,12 @@ const log = () => console.log(formatQuery(manager.getQuery(), 'sql'));
```
-`QueryManager` keeps its history in private class fields, which a reactive `Proxy` cannot read
-through. Do not wrap a manager in `reactive()`; if you must, `toRaw()` it before calling it.
+A manager may be wrapped in `reactive()` — Vue Test Utils does exactly that to mount props, so
+it happens by accident more often than by choice. As of `@react-querybuilder/core` 8.23.0 the
+manager's state lives in a non-enumerable, symbol-keyed own property, which reads correctly
+through a `Proxy`, and that property is flagged so `reactive()` will not deep-proxy the internals
+either. No `toRaw()` is required. (Before 8.23.0 the state was in `#private` fields and every
+call through a proxy threw `Cannot read private member #past`.)
## 4. Query binding
@@ -156,8 +160,10 @@ Consequences worth spelling out:
directly to `QueryBuilder` beats an inherited slot, because levels are tried before sources.
- `controlElements: { x: null }` short-circuits at its own level, so it renders nothing even when
an outer provider supplies an `#x` slot.
-- Bulk sources are `actionElement` (keys ending `Action`/`Actions`) and `valueSelector` (keys
- ending `Selector`). They never apply to `valueEditor`, `rule`, `ruleGroup`, `inlineCombinator`,
+- Bulk sources are `actionElement` and `valueSelector`. Membership comes from core's
+ `controlKind`, not from sniffing the key name: `shiftActions` and `undoRedoActions` (plural)
+ are **not** targets of the `actionElement` bulk override, matching React. Their buttons still
+ render through the `actionElement` control, so an override reaches them that way. They never apply to `valueEditor`, `rule`, `ruleGroup`, `inlineCombinator`,
`notToggle`, or `matchModeEditor`.
Because slots must be inheritable, they also have a prop form: `QueryBuilderContextProps.slots`,
@@ -271,10 +277,13 @@ Notes:
rebuilt on every render does not retrigger it.
- **`ValueSelector` drives a multi-select through each `