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
24 changes: 17 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
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.

- Option lists (fields, combinators, operators, values) are now prepared by the `QueryManager`, which takes `translations` and therefore produces the placeholder options itself when `autoSelectField`/`autoSelectOperator`/`autoSelectValue` is `false`. `createQueryBuilderState` reads the lists back off the manager (`getFields`, `getCombinators`, `getOperators`, `getValues`) instead of running `prepareOptionList` a second time, so the rendered lists and the values the manager assigns to new rules can no longer disagree. The remaining resolver props are forwarded to the manager as-is.
- Structural options are fully reactive again, and now stay in sync with the manager. Changing `fields`, `operators`, `combinators`, `translations`, `maxLevels`, `disabled`, `validator`, `idGenerator`, or the `autoSelect*` flags after the first render applies them to the existing manager through `QueryManager#reconfigure` (`@react-querybuilder/core` 8.22.3), so the query, the undo/redo history, and every subscriber survive the change. Previously these were captured at construction and could only be changed by recreating the component. There is no opt-out. A `manager` passed through the `manager` prop is never reconfigured.
- `getDefaultField` is forwarded through a live closure like every other resolver prop, so a changed function prop takes effect without a reconfigure.
- Minimum `@react-querybuilder/core` is now 8.22.3.
### Removed

### Fixed
- **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`.

### Changed

- `onQueryChange` (and the `bind:query` write-back) no longer fires for a configuration-only manager notification, which would previously have re-emitted an unchanged query.
- **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`.
- `onQueryChange` fires once during initialization if and only if the initial query was seeded or normalized by the component — no query supplied, or one supplied without `id`s. A query handed over ready to use never triggers it. This is what `enableMountQueryChange` used to control, minus the flag.
- The `query` prop is documented as an input rather than the authority: it wins whenever it changes, and local edits stand in between. Note that a `query` prop rebuilt as a fresh object on every read is indistinguishable from a deliberate change and will revert every edit; pass a stable reference.
- Option lists are `$derived(prepareOptionList(...))` rather than read back off a manager, and structural options (`fields`, `operators`, `combinators`, `translations`, `maxLevels`, `disabled`, `validator`, `idGenerator`, the `autoSelect*` flags) are re-derived from props instead of pushed into a mutable instance via `reconfigure`. Changing them mid-session still preserves the query and the undo/redo history.
- Undo/redo history is two `$state.raw` stacks with coalescing delegated to core's `shouldCoalesce`, so the coalescing rule cannot drift from core's.
- `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`.

## [0.1.1] - 2026-08-05

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,17 @@ Requires Svelte 5.25 or later. `@react-querybuilder/core` comes along as a depen

## Driving the query

Four options, in increasing order of control:
Three options, in increasing order of control:

| Approach | Use when |
| ------------------------- | ----------------------------------------------------------------------- |
| `defaultQuery` | Uncontrolled — the component owns the query. |
| `bind:query` | The common case. Two-way binding via `$bindable`. |
| `query` + `onQueryChange` | Fully controlled, e.g. when the query lives in a store or is validated. |
| `manager` | A `QueryManager` you construct and hold, driven from outside the tree. |

Undo/redo, history, and programmatic mutation all go through a `QueryManager`.
The `query` prop is an input, not the authority: it wins whenever it changes, and local edits stand in between. Pass a stable reference — rebuilding it on every read (`query={{ ...myQuery }}`) reverts every edit as fast as it is applied.

Undo/redo and history are built in; render the controls with `showUndoRedo`.

## Styling

Expand Down Expand Up @@ -78,7 +79,7 @@ Not in v1, and not planned for the near term:
- UI-framework compatibility packages (Ant Design, Bootstrap, MUI, etc.)
- `@react-querybuilder/expr` / `@react-querybuilder/datetime` UI integrations
- Async option lists
- A Redux store or a `qbId` registry — hold a `QueryManager` instance instead
- A Redux store or a `qbId` registry — hold the query yourself and use `bind:query`
- Deprecated props carried over from React Query Builder

## License
Expand Down
8 changes: 5 additions & 3 deletions bun.lock

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

31 changes: 18 additions & 13 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,20 @@ Context carries configuration — `controlElements`, `controlClassnames`, `trans
import { setQueryBuilderContext } from 'svelte-querybuilder';
import MyValueEditor from './MyValueEditor.svelte';

setQueryBuilderContext({
// `setQueryBuilderContext` takes a *getter*, not a value.
setQueryBuilderContext(() => ({
controlElements: { valueEditor: MyValueEditor },
translations: { addRule: { label: 'Add' } },
showNotToggle: true,
});
}));
</script>
```

Context is set once, during component initialization. If any value has to stay reactive, pass an object of getters rather than a plain snapshot:
Context is set once, during component initialization, so the argument is a getter rather than a value. Descendants call it from inside their own derivations, which is what keeps reactive values live:

```svelte
setQueryBuilderContext({
get showNotToggle() {
return showNotToggle;
},
});
let showNotToggle = $state(true); // Read inside the getter, so descendants see every change.
setQueryBuilderContext(() => ({showNotToggle}));
```

Props always win over context, per key.
Expand Down Expand Up @@ -158,19 +156,26 @@ Replacing `rule` or `ruleGroup` wholesale is a larger job, because those compone

## Driving the query from outside

To manipulate the query from outside the component tree, construct a `QueryManager` and pass it in:
Hold the query yourself and bind it. There is no `manager` prop — query state is a rune owned by the component, and `bind:query` is the supported way in and out:

```svelte
<script lang="ts">
import { QueryBuilder, QueryManager } from 'svelte-querybuilder';
import { QueryBuilder, add } from 'svelte-querybuilder';

const manager = new QueryManager({ combinator: 'and', rules: [] }, { history: true });
let query = $state({ combinator: 'and', rules: [] });

// Core's pure query tools are re-exported from the barrel. `freeze: false` because immer's
// deep freeze throws on the Svelte `$state` proxies the query is made of.
const addRule = () =>
(query = add(query, { field: 'firstName', operator: '=', value: '' }, [], { freeze: false }));
</script>

<button onclick={() => manager.undo()}>Undo</button>
<QueryBuilder {fields} {manager} />
<button onclick={addRule}>Add rule</button>
<QueryBuilder {fields} bind:query />
```

Undo/redo is internal to the component; render its controls with `showUndoRedo` rather than driving it from outside.

## Classnames

`controlClassnames` appends to the standard classes rather than replacing them, so `queryBuilder-invalid` and friends keep working:
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 @@ -24,27 +24,14 @@ Element structure, document order, class names, `data-testid`s, and `data-path`

React Query Builder v8 keeps query state in a Redux store, addressed by a `qbId` registry, and exposes `dispatchQuery`/`useQueryBuilderQuery` for external access.

This package has no store and no registry. All state lives in a `QueryManager` instance owned by the component. To drive the query from outside the component tree, construct one yourself and pass it in:

```svelte
<script lang="ts">
import { QueryBuilder, QueryManager } from 'svelte-querybuilder';

const manager = new QueryManager({ combinator: 'and', rules: [] }, { fields, history: true });

const clear = () => manager.setQuery({ combinator: 'and', rules: [] });
</script>

<QueryBuilder {fields} {manager} />
<button onclick={clear}>Clear</button>
<button onclick={() => manager.undo()} disabled={!manager.canUndo()}>Undo</button>
```
This package has no store, no registry, and no `QueryManager`. Query state is a Svelte rune owned by the component; the query itself is manipulated with the same pure functions React Query Builder uses (`add`, `remove`, `update`, `move`), re-exported from this package's barrel.

Consequences:

- No `qbId` prop, no `dispatchQuery`, no `useQueryBuilderQuery` equivalent.
- No `preserveQueryStateOnUnmount` — there is no store to preserve state in.
- Undo/redo needs no separate entry point. React splits it into `react-querybuilder/history`; here the component's manager is always constructed with history enabled, and `showUndoRedo` renders the controls.
- No `manager` prop and no `schema.manager`. To drive the query from outside the component tree, hold it yourself and use `bind:query`, or pass `query` + `onQueryChange`.
- Undo/redo needs no separate entry point. React splits it into `react-querybuilder/history`; here history is always on and `showUndoRedo` renders the controls. `schema.history` exposes `canUndo`, `canRedo`, `undo`, `redo`, and `clear`.

## Query binding

Expand All @@ -56,6 +43,16 @@ React accepts `query` + `onQueryChange` (controlled) or `defaultQuery` (uncontro

Controlled mode compares the incoming query structurally, not just by reference, because a parent holding the query in `$state` hands back a reactive proxy that is never reference-equal to the object the query builder emitted.

The `query` prop is an _input_, not the authority: it wins whenever it **changes**, and local edits stand in between. That covers every driving mode — a controlled consumer updates the prop from `onQueryChange`, an uncontrolled one never passes it at all, and `bind:query` does both.

> [!WARNING]
> Do not rebuild the `query` prop as a fresh object on every read. An expression like
> `query={structuredClone(myQuery)}` or `query={{ ...myQuery }}` produces a new, structurally
> stale object each time the prop is read, which is indistinguishable from a prop the consumer
> deliberately changed. It wins every time, reverting each edit as fast as it is applied — the
> builder will appear frozen. Pass a stable reference and reassign it only when the query
> actually changes.

## Customization

`controlElements` works as it does in React, with Svelte components instead of React ones:
Expand Down Expand Up @@ -92,7 +89,7 @@ React has no equivalent; `controlElements` is its only component-level customiza

- `ReactNode` → `LabelNode` (`Snippet | string`).
- `ComponentType<P>` → Svelte's `Component<P>`.
- `Schema` drops `dispatchQuery` and `qbId`, and gains `manager: QueryManager`.
- `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.
Expand All @@ -104,4 +101,6 @@ React's hooks have no direct equivalents, and the `useMemo` graphs in `Rule`/`Ru

## Known behavioral note

Structural manager options — `fields`, `operators`, `combinators`, `translations`, `maxLevels`, `disabled`, and the boolean flags — are applied to the existing `QueryManager` in place via `QueryManager#reconfigure` whenever the corresponding prop changes. The query, the undo/redo history, and any subscribers survive, so changing `fields` mid-session updates both the rendered selectors and the defaults the manager assigns to newly created rules without losing state. A config-only change does not fire `onQueryChange`. A `manager` supplied through the `manager` prop is never reconfigured — that instance belongs to the caller.
Structural options — `fields`, `operators`, `combinators`, `translations`, `maxLevels`, `disabled`, and the boolean flags — are derived from props, so changing one mid-session updates both the rendered selectors and the defaults assigned to newly created rules without touching the query or the undo/redo history. A config-only change does not fire `onQueryChange`.

`onQueryChange` fires once during initialization if and only if the initial query was **seeded or normalized** by the component — that is, no query was supplied, or one was supplied without `id`s and had to be prepared. A query handed over ready to use never triggers it. This replaces React's `enableMountQueryChange` flag, which no longer exists.
2 changes: 1 addition & 1 deletion packages/svelte-querybuilder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"svelte": "^5.25"
},
"dependencies": {
"@react-querybuilder/core": "^8.22.3"
"@react-querybuilder/core": "https://pkg.pr.new/react-querybuilder/react-querybuilder/@react-querybuilder/core@3d5d992"
Comment thread
jakeboone02 marked this conversation as resolved.
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!--
@component
Test-only harness: a parent that owns the query and hands it to `QueryBuilder` with
`bind:query`, reporting every value it sees through `report`. Named `*.test.svelte` so the
build strips it from `dist`.
-->
<script lang="ts">
import type { FullField, RuleGroupType } from '@react-querybuilder/core';
import QueryBuilder from './QueryBuilder.svelte';

const {
fields,
report,
}: { fields: FullField[]; report: (query: RuleGroupType | undefined) => void } = $props();

let query = $state<RuleGroupType | undefined>();

$effect(() => {
report(query);
});
</script>

<QueryBuilder {fields} bind:query />
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
level={0} />
{#if requiresThreshold(props.match.mode)}
<NumericEditorComponent
skipHook
skipValueReset
testID={props.testID}
inputType="number"
title={props.title}
Expand Down
Loading