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 `