Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
27 changes: 23 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ package barrel:
<script setup lang="ts">
import type { ValueEditorProps } from '@react-querybuilder/vue';

defineOptions({ inheritAttrs: false });
const props = defineProps<ValueEditorProps>();
</script>

Expand All @@ -195,8 +194,11 @@ const props = defineProps<ValueEditorProps>();
</template>
```

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.
Expand Down
25 changes: 17 additions & 8 deletions docs/differences-from-react-querybuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,12 @@ const log = () => console.log(formatQuery(manager.getQuery(), 'sql'));
</template>
```

`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

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -271,10 +277,13 @@ Notes:
rebuilt on every render does not retrigger it.
- **`ValueSelector` drives a multi-select through each `<option>`'s `selected` attribute**, not a
`value` binding, which Vue would stringify into a cleared selection. Rendered DOM is unchanged.
- **Every default control sets `inheritAttrs: false`.** `Rule` and `RuleGroup` hand each
subcomponent a common prop bag (`rule`, `rules`, `ruleOrGroup`, `fieldData`, …) that most
controls do not declare; without this, Vue would land them on the DOM as stray attributes React
never emits. A custom control should do the same.
- **Attribute fallthrough is on for every default control**, so a consumer-supplied `class`,
`id`, or listener behaves the way a Vue developer expects. Nothing strays onto the DOM on its
own: each control declares every prop core's `controlPropKeys` says it receives, and the call
sites pass nothing beyond that. Both halves are gated — at runtime by
`components/controlProps.test.ts` and at compile time by `types/types.test-d.ts` — so drift
from React surfaces as a failing check rather than as a stray attribute. A replacement control
that does not declare the full prop set should set `inheritAttrs: false`.
- **Every boolean prop is declared with an explicit `undefined` default.** Vue casts an omitted
`Boolean` prop to `false`, which is not the same as "not configured" — `autoSelectField`,
`enableMountQueryChange`, and the `resetOn*` flags all default to `true`, and a stray `false`
Expand Down
2 changes: 1 addition & 1 deletion packages/vue-querybuilder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"vue": "^3.5"
},
"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",
Expand Down
9 changes: 5 additions & 4 deletions packages/vue-querybuilder/src/components/ActionElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import type { ActionProps } from '../types/props.js';
*
* Port of React Query Builder's `ActionElement` (`ActionElement.tsx`).
*/
// `inheritAttrs: false`: `Rule`/`RuleGroup` pass every subcomponent a common set of props
// (`rule`, `rules`, `ruleOrGroup`, ...) that this component does not declare. Without this they
// would fall through onto the `<button>` as stray attributes, which React never emits.
defineOptions({ name: 'ActionElement', inheritAttrs: false });
// Attribute fallthrough is on, as a Vue developer expects: a consumer-supplied `class`, `id`,
// or listener lands on the `<button>`. Nothing strays there on its own — `ActionProps` declares
// every prop core's `controlPropKeys` says an action control receives, and the call sites pass
// nothing beyond that. `controlProps.test.ts` gates both halves.
defineOptions({ name: 'ActionElement' });

const props = defineProps<ActionProps>();

Expand Down
4 changes: 2 additions & 2 deletions packages/vue-querybuilder/src/components/InlineCombinator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type { CombinatorSelectorProps, InlineCombinatorProps } from '../types/pr
*
* Port of React Query Builder's `InlineCombinator` (`InlineCombinator.tsx`).
*/
// `inheritAttrs: false`: see `ActionElement.vue`.
defineOptions({ name: 'InlineCombinator', inheritAttrs: false });
// Attribute fallthrough is on; see `ActionElement.vue`.
defineOptions({ name: 'InlineCombinator' });

const props = defineProps<InlineCombinatorProps>();

Expand Down
7 changes: 4 additions & 3 deletions packages/vue-querybuilder/src/components/MatchModeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import type { Schema } from '../types/schema.js';
* Both controls carry the same `testID`, as upstream does, so tests reach the threshold editor
* with `getAllByTestId(...)[1]`.
*/
// `inheritAttrs: false`: see `ActionElement.vue`. This component renders two roots, so Vue would
// not know where to put fallthrough attributes anyway.
defineOptions({ name: 'MatchModeEditor', inheritAttrs: false });
// Attribute fallthrough is on; see `ActionElement.vue`. This component renders two roots, so a
// stray attribute would draw a Vue warning rather than land silently — which is the behavior a
// Vue developer expects, and `controlProps.test.ts` proves none strays.
defineOptions({ name: 'MatchModeEditor' });
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const dummyFieldData: FullField = { name: '', value: '', label: '' };
const dummyPath: Path = [];
Expand Down
13 changes: 9 additions & 4 deletions packages/vue-querybuilder/src/components/NotToggle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,16 @@ describe('NotToggle', () => {
expect(nodes[1].textContent).toBe('Not');
});

it('does not let undeclared props fall through as attributes', () => {
// Attribute fallthrough is enabled, so a consumer-supplied attribute reaches the DOM and a
// consumer-supplied `class` merges with the control's own. Nothing the port passes internally
// strays here; `controlProps.test.ts` is the gate for that.
it('passes consumer-supplied attributes through to the root element', () => {
const { getByTestId } = render(NotToggle, {
props: { ...baseProps(), testID: 'x' },
attrs: { rules: [] },
props: { ...baseProps(), className: 'own-cn', testID: 'x' },
attrs: { id: 'consumer-id', class: 'consumer-cn' },
});
expect(getByTestId('x')).not.toHaveAttribute('rules');
const el = getByTestId('x');
expect(el).toHaveAttribute('id', 'consumer-id');
expect(el).toHaveClass('own-cn', 'consumer-cn');
});
});
6 changes: 2 additions & 4 deletions packages/vue-querybuilder/src/components/NotToggle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import type { NotToggleProps } from '../types/props.js';
*
* Port of React Query Builder's `NotToggle` (`NotToggle.tsx`).
*/
// `inheritAttrs: false`: `RuleGroup` passes every subcomponent a common set of props
// (`ruleGroup`, `rules`, ...) that this component does not declare. Without this they would
// fall through onto the `<label>` as stray attributes, which React never emits.
defineOptions({ name: 'NotToggle', inheritAttrs: false });
// Attribute fallthrough is on; see `ActionElement.vue`.
defineOptions({ name: 'NotToggle' });

const props = defineProps<NotToggleProps>();

Expand Down
7 changes: 4 additions & 3 deletions packages/vue-querybuilder/src/components/ShiftActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ describe('ShiftActions', () => {
expect(getByTestId('x')).toBeInTheDocument();
});

it('does not let undeclared props fall through as attributes', () => {
// See `NotToggle.test.ts`: fallthrough is the point of dropping `inheritAttrs: false`.
it('passes consumer-supplied attributes through to the root element', () => {
const { getByTestId } = render(ShiftActions, {
props: { ...baseProps(), testID: 'x' },
attrs: { rules: [] },
attrs: { id: 'consumer-id' },
});
expect(getByTestId('x')).not.toHaveAttribute('rules');
expect(getByTestId('x')).toHaveAttribute('id', 'consumer-id');
});
});
Loading