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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Runes only. No Svelte 4 idioms — no `export let`, no `$:`, no stores for compo
- `$derived` / `$derived.by` instead of `useMemo`. Fine-grained reactivity means manual memoization is almost never needed — don't port React's memoization.
- `$state` for local mutable state; `$effect` only as a last resort (prefer `$derived`)
- Callback props (`onQueryChange`), not events
- `{#snippet}` / `{@render}` for slot-like customization instead of `controlElements` component maps where it reads better; keep an escape hatch for passing custom components
- `{#snippet}` / `{@render}` for slot-like customization: each control is a top-level snippet prop, with the `controls` object as the escape hatch for passing components. Snippets and components are indistinguishable at runtime, so a snippet used as a control is wrapped as `{ snippet }` (see `internal/Control.svelte`) — never invoke a compiled component or snippet by hand
- `setContext`/`getContext` for cross-tree config instead of prop drilling — but context is set once at init, so pass a getter or a `$state` object if the value must stay reactive

### TypeScript
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Query state is now owned entirely by Svelte runes. `QueryManager` is gone from this package: core's pure functions (`add`/`remove`/`update`/`move`, `createQueryActions`, `prepareOptionList`, `deriveRuleContext`, `shouldCoalesce`) supply the logic, and the reactive graph lives in `$state`/`$derived`. `createQueryBuilderState` contains no `$effect` at all, and no longer relies on deep-compare, live closures, a config-version counter, subscription mirroring, or try/catch around immer's freeze.

Control elements are now composed the Svelte way. Each of the 24 control names is a top-level snippet prop, so `{#snippet valueEditor(props)}` works as a direct child of `<QueryBuilder>`, and the internal component-ABI trick that used to make snippets and components interchangeable is gone.

### Removed

- **Breaking:** the `controlElements` prop, replaced by `controls` (see below).
- **Breaking:** `ControlSnippets` and the 24 `${key}Snippet` props it generated. There is now one name per control.
- `snippetToComponent` and `SnippetHost`, which fabricated a component from a snippet by invoking a compiled `.svelte` module through Svelte's undocumented `(anchor | payload, props)` calling convention, plus the `WeakMap` that kept the fabricated components identity-stable. Nothing in the package relies on Svelte internals now.
- `nullComponent`. A `null` control short-circuits in the renderer instead of rendering an empty component.
- **Breaking:** the `manager` prop and `schema.manager`. External `QueryManager` control was speculative, unused, and the one thing runes cannot own. Hold the query yourself and use `bind:query`, or `query` + `onQueryChange`.
- **Breaking:** `enableMountQueryChange`. Its behavior is now derived from first principles — see below.
- `createRuleContext` and `createRuleGroupContext`, along with the `Derived<T>` (`{ readonly current: T }`) wrapper type. `createRuleParts`/`createRuleGroupParts` are the supported path and return getters directly.
- `createActions`, superseded by core's `createQueryActions`.

### Added

- Top-level snippet props for every control: `valueEditor`, `removeRuleAction`, `ruleGroup`, `actionElement`, `valueSelector`, and so on. A snippet declared inside a component's tags only becomes a prop when the name is top-level, which is what makes the idiomatic form reachable.
- `controls`, the bulk object form, for configuration assembled programmatically. It accepts components, `null`, and snippets wrapped as `{ snippet }`.

### Changed

- **Breaking:** control elements are typed `Control<P> = Component<P> | { snippet: Snippet<[P]> }`, or `null`. Snippets and components are both plain functions at runtime with no reliable way to tell them apart, so a snippet used as a control carries a wrapper object; the top-level snippet props wrap automatically. `ControlElementsProp` is now `ControlsProp`, and `ControlPropsMap` is the single source of truth for control names and their prop types.
- **Breaking:** `Controls` entries are uniformly nullable — including `actionElement`, `valueSelector`, `rule`, and `ruleGroup` — with `null` meaning "render nothing". Every key is always present after resolution.
- **Breaking:** `selectorComponent`, `numericEditorComponent`, and `InlineCombinatorProps.component` accept a `Control`, so a `valueSelector` supplied as a snippet applies inside `ValueEditor` and `MatchModeEditor` too.
- **Breaking:** `mergeControlElements` is now `mergeControls(controls, snippets, contextControls, contextSnippets, defaults)`.
- A query builder publishes its _resolved_ controls through context, so a nested (subquery) builder inherits what the outer one resolved and overrides it per key with its own props.
- **Breaking:** `schema.manager` is replaced by `schema.history` — `canUndo`, `canRedo`, `undo`, `redo`, `clear`. Backed by getters, so reads stay reactive without dependency pokes.
- **Breaking:** the `skipHook` option is renamed `skipValueReset` on `MatchModeEditor` and the value-editor reset. It suppresses the value reset, which is what the name now says.
- **Breaking:** `shiftActions` and `undoRedoActions` no longer receive the `actionElement` bulk control override, despite the plural suffix. Bulk classification now uses core's explicit `controlKind` map instead of matching on key suffixes, so a control named `somethingSelector` can no longer silently inherit `valueSelector`.
Expand All @@ -28,6 +44,10 @@ Query state is now owned entirely by Svelte runes. `QueryManager` is gone from t
- `QueryBuilder` publishes context as `setQueryBuilderContext(() => state.context)` rather than an `Object.defineProperty` reflection loop, so the key set is no longer snapshotted at initialization. `getQueryBuilderContext` returns a getter.
- Minimum `@react-querybuilder/core` is now 8.23.0, for the query-tool `freeze` opt-out (deep-freezing a Svelte `$state` proxy throws), `shouldCoalesce`, `controlKeys`/`controlKind`, and `DefaultFieldProp`/`DefaultOperatorProp`.

### Fixed

- Mounting a query with rules whose `value` no longer matches their `operator` — the ones for which core's `getValueEditorReset` returns `reset: true` — is roughly 40x faster. Each such rule commits a query change during mount, and every commit was re-dirtying every prop of every control in the tree, so the cost grew quadratically in the number of reset-eligible rules (~1s for a two-rule case in an eight-rule tree). Control prop bags are now getter-backed objects built once, rather than `$derived` object literals rebuilt per commit: `Control` forwards them through `{...props}`, and Svelte's `spread_props` resolves one key at a time, so each of a control's props subscribes to only its own sources instead of to the union of all of them. Interactive editing was never affected.

## [0.1.1] - 2026-08-05

### Fixed
Expand Down
58 changes: 35 additions & 23 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Every part of the rendered tree can be replaced. There are three levels, in order of increasing reach:

1. **Translations** — change the text (or markup) of a label or tooltip.
2. **Snippets and `controlElements`** — replace an individual control.
2. **Snippets and `controls`** — replace an individual control.
3. **Context** — apply either of the above to every query builder in a subtree.

Before replacing a component, check whether [styling](./styling.md) gets you there.
Expand Down Expand Up @@ -36,27 +36,29 @@ Titles are plain strings — they end up in a `title` attribute, which cannot ho

## Replacing a control

Each control has two interchangeable customization points: a snippet prop and a `controlElements` entry.
Every control has one name — `valueEditor`, `removeRuleAction`, `ruleGroup`, and so on — and two ways to supply a replacement: a snippet on the top-level prop of that name, or a component in the `controls` object.

The split is not arbitrary. Snippets and components are both plain functions at runtime with no reliable way to tell them apart, so each channel is typed for exactly one kind. Snippets get the top-level prop because a `{#snippet}` declared inside a component's tags only becomes a prop when the name is top-level — it cannot populate a nested object.

### Snippet props

For every key `x` of `controlElements` there is an `xSnippet` prop. The snippet takes one argument: the props object the default component would have received.
The snippet takes one argument: the props object the default component would have received.

```svelte
{#snippet valueEditorSnippet(props)}
<input
class={props.className}
value={props.value}
disabled={props.disabled}
oninput={e => props.handleOnChange(e.currentTarget.value)} />
{/snippet}

<QueryBuilder {fields} bind:query {valueEditorSnippet} />
<QueryBuilder {fields} bind:query>
{#snippet valueEditor(props)}
<input
class={props.className}
value={props.value}
disabled={props.disabled}
oninput={e => props.handleOnChange(e.currentTarget.value)} />
{/snippet}
</QueryBuilder>
```

Snippets are the better fit when the replacement is small, needs values from the surrounding scope, or is only used once.

### `controlElements`
### The `controls` prop

Pass a Svelte component instead. Better fit when the replacement is reusable or needs its own state:

Expand All @@ -65,33 +67,43 @@ Pass a Svelte component instead. Better fit when the replacement is reusable or
import MyValueEditor from './MyValueEditor.svelte';
</script>

<QueryBuilder {fields} bind:query controlElements={{ valueEditor: MyValueEditor }} />
<QueryBuilder {fields} bind:query controls={{ valueEditor: MyValueEditor }} />
```

Passing `null` renders nothing:
`null` renders nothing:

```svelte
<QueryBuilder {fields} bind:query controlElements={{ lockRuleAction: null }} />
<QueryBuilder {fields} bind:query controls={{ lockRuleAction: null }} />
```

A snippet can go in `controls` too, wrapped in `{ snippet }`, for configuration assembled programmatically:

```svelte
<QueryBuilder {fields} bind:query controls={{ valueEditor: { snippet: myRawSnippet } }} />
```

### Bulk overrides

`actionElement`/`actionElementSnippet` replaces every button-type control at once (`addRuleAction`, `removeGroupAction`, `shiftActions`, …), and `valueSelector`/`valueSelectorSnippet` replaces every `<select>`-type control (`fieldSelector`, `operatorSelector`, `combinatorSelector`, `valueSourceSelector`). Neither applies to `valueEditor`, `rule`, `ruleGroup`, `inlineCombinator`, `notToggle`, or `matchModeEditor`.
`actionElement` replaces every button-type control at once (`addRuleAction`, `removeGroupAction`, `cloneRuleAction`, …), and `valueSelector` replaces every `<select>`-type control (`fieldSelector`, `operatorSelector`, `combinatorSelector`, `valueSourceSelector`). Both work as a snippet prop or a `controls` entry. Neither applies to `valueEditor`, `rule`, `ruleGroup`, `inlineCombinator`, `notToggle`, or `matchModeEditor`.

Which controls are "actions" and which are "selectors" comes from core's `controlKind` map, not from the shape of the name — `shiftActions` and `undoRedoActions` are composites and are not bulk-action targets despite the plural suffix.

## Resolution order

Each control key is resolved independently. Levels are tried in order — props, then inherited context, then the package defaults — and within a level:

1. the keyed snippet (`valueEditorSnippet`)
2. the keyed component (`controlElements.valueEditor`), where `null` means "render nothing" and stops the search
3. the bulk snippet (`valueSelectorSnippet`)
4. the bulk component (`controlElements.valueSelector`)
1. the keyed snippet (the `valueEditor` prop)
2. the keyed entry (`controls.valueEditor`), where `null` means "render nothing" and stops the search
3. the bulk snippet (the `valueSelector` prop)
4. the bulk entry (`controls.valueSelector`)

So a snippet passed to `QueryBuilder` beats a component passed to `QueryBuilder`, which beats anything inherited from context, which beats the default.

## Applying customization to a subtree

Context carries configuration — `controlElements`, `controlClassnames`, `translations`, and the boolean flags — down to every query builder below it, including the subquery builders that match modes create.
Context carries configuration — `controls`, `controlClassnames`, `translations`, and the boolean flags — down to every query builder below it, including the subquery builders that match modes create.

A query builder publishes its _resolved_ controls to its descendants, so a nested builder inherits whatever the outer one ended up with, and still overrides it per key with its own props.

```svelte
<script lang="ts">
Expand All @@ -100,7 +112,7 @@ Context carries configuration — `controlElements`, `controlClassnames`, `trans

// `setQueryBuilderContext` takes a *getter*, not a value.
setQueryBuilderContext(() => ({
controlElements: { valueEditor: MyValueEditor },
controls: { valueEditor: MyValueEditor },
translations: { addRule: { label: 'Add' } },
showNotToggle: true,
}));
Expand Down
35 changes: 17 additions & 18 deletions docs/differences-from-react-querybuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Element structure, document order, class names, `data-testid`s, and `data-path`
| Feature | Status |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Drag-and-drop (`@react-querybuilder/dnd`) | Non-goal. `enableDragAndDrop` is not accepted; the root always renders `data-dnd="disabled"`. |
| UI-framework packages (Ant Design, Bootstrap, MUI, Chakra, …) | Non-goal. Use `controlElements` to supply your own components. |
| UI-framework packages (Ant Design, Bootstrap, MUI, Chakra, …) | Non-goal. Use `controls` or a control snippet to supply your own components. |
| `@react-querybuilder/expr`, `@react-querybuilder/datetime` UI | Non-goal for v1. |
| `useAsyncOptionList` / async option lists | Non-goal for v1. Resolve options before passing them as `fields`. |
| Deprecated props and their fallbacks | Dropped. `RuleGroupProps.combinator`/`rules`/`not` and `RuleProps.field`/`operator`/`value`/`valueSource` are not read; use `ruleGroup`/`rule`. Deprecated type aliases (`ActionWithRulesProps` and friends) are gone. |
Expand Down Expand Up @@ -55,15 +55,23 @@ The `query` prop is an _input_, not the authority: it wins whenever it **changes

## Customization

`controlElements` works as it does in React, with Svelte components instead of React ones:
RQB's `controlElements` object is replaced by two channels, both keyed by the same control names:

```svelte
<QueryBuilder {fields} bind:query controlElements={{ valueEditor: MyValueEditor }} />
<!-- A snippet, on a top-level prop -->
<QueryBuilder {fields} bind:query>
{#snippet valueEditor(props)}
<MyInput value={props.value} oninput={e => props.handleOnChange(e.currentTarget.value)} />
{/snippet}
</QueryBuilder>

<!-- A component, in the `controls` object -->
<QueryBuilder {fields} bind:query controls={{ valueEditor: MyValueEditor }} />
```

Passing `null` for a control renders nothing, same as React.
`{#snippet}` only becomes a prop when the name is top-level, which is why the control names are hoisted out of the object. Snippets and components are indistinguishable at runtime, which is why each channel is typed for one kind; a snippet can still go in `controls` wrapped as `{ snippet }`. `null` renders nothing, same as React. Top-level snippets take precedence over `controls`.

Snippets are accepted for translatable labels anywhere React accepts a `ReactNode` — the `LabelNode` type is `Snippet | string`:
Snippets are also accepted for translatable labels anywhere React accepts a `ReactNode` — the `LabelNode` type is `Snippet | string`:

```svelte
{#snippet addRuleLabel()}
Expand All @@ -73,17 +81,7 @@ Snippets are accepted for translatable labels anywhere React accepts a `ReactNod
<QueryBuilder {fields} bind:query translations={{ addRule: { label: addRuleLabel } }} />
```

Every control element also has a snippet prop — `valueEditorSnippet`, `ruleSnippet`, `actionElementSnippet`, and so on — which takes precedence over the corresponding `controlElements` entry:

```svelte
{#snippet valueEditorSnippet(props)}
<MyInput value={props.value} oninput={e => props.handleOnChange(e.currentTarget.value)} />
{/snippet}

<QueryBuilder {fields} bind:query {valueEditorSnippet} />
```

React has no equivalent; `controlElements` is its only component-level customization point. See [customization.md](./customization.md) for the full resolution order.
See [customization.md](./customization.md) for the full resolution order.

## Type-level differences

Expand All @@ -92,8 +90,9 @@ React has no equivalent; `controlElements` is its only component-level customiza
- `Schema` drops `dispatchQuery` and `qbId`, and gains `history` (`canUndo`/`canRedo`/`undo`/`redo`/`clear`).
- `QueryBuilderProps` has defaults for all four type parameters (`RuleGroupType`, `FullField`, `FullOperator`, `FullCombinator`), so bare `QueryBuilderProps` is valid. React requires all four.
- `ActionProps.handleOnClick` and `ShiftActionsProps.shiftUp`/`shiftDown` take a DOM `MouseEvent`, not React's synthetic `MouseEvent`.
- `Controls['undoRedoActions']` is non-nullable. React keeps it nullable because no implementation ships in the base package.
- `ControlSnippets` has no React counterpart: for every key `x` of `ControlElementsProp` there is an `xSnippet` prop taking `Snippet<[props]>`.
- `Controls` entries are uniformly nullable, `null` meaning "render nothing". Unlike React, `undoRedoActions` has a default implementation, so it is never unset.
- `ControlElementsProp` → `ControlsProp` (the `controls` prop), plus `ControlSnippetProps`, which has no React counterpart: one top-level `Snippet<[props]>` prop per control name.
- A resolved control is `Control<P> = Component<P> | { snippet: Snippet<[P]> }`, or `null` for "render nothing"; `ControlPropsMap` is the single source of truth for control names and their props.

## Reactivity

Expand Down
Loading