From 6e66f77584697b8694195e7599c348dafe121105 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Thu, 13 Aug 2026 07:38:49 -0700 Subject: [PATCH 1/2] Rune-owned state --- CHANGELOG.md | 24 +- README.md | 9 +- bun.lock | 8 +- docs/customization.md | 15 +- docs/differences-from-react-querybuilder.md | 35 +- packages/svelte-querybuilder/package.json | 2 +- .../src/lib/components/MatchModeEditor.svelte | 2 +- ...st.ts => QueryBuilder.propchanges.test.ts} | 20 +- .../src/lib/components/QueryBuilder.svelte | 20 +- .../src/lib/components/QueryBuilder.test.ts | 16 +- .../src/lib/components/RuleSubQuery.svelte | 15 +- .../src/lib/components/UndoRedoActions.svelte | 26 +- .../lib/components/UndoRedoActions.test.ts | 17 +- .../src/lib/components/ValueEditor.svelte | 2 +- .../src/lib/reactive/context.svelte.ts | 98 +-- .../src/lib/reactive/context.test.ts | 8 +- .../src/lib/reactive/createActions.svelte.ts | 174 ---- .../src/lib/reactive/createActions.test.ts | 257 ------ .../createQueryBuilderState.svelte.test.ts | 157 +++- .../createQueryBuilderState.svelte.ts | 747 ++++++++++-------- .../src/lib/reactive/index.ts | 5 +- .../lib/reactive/ruleContext.svelte.test.ts | 120 --- .../src/lib/reactive/ruleContext.svelte.ts | 48 -- .../lib/reactive/ruleGroupContext.svelte.ts | 36 - .../reactive/valueEditorEffect.svelte.test.ts | 4 +- .../lib/reactive/valueEditorEffect.svelte.ts | 7 +- .../src/lib/types/props.ts | 26 +- .../src/lib/types/schema.ts | 31 +- .../src/lib/types/types.test-d.ts | 11 +- .../test/conformance/actions.svelte.test.ts | 122 ++- 30 files changed, 790 insertions(+), 1272 deletions(-) rename packages/svelte-querybuilder/src/lib/components/{QueryBuilder.reconfigure.test.ts => QueryBuilder.propchanges.test.ts} (90%) delete mode 100644 packages/svelte-querybuilder/src/lib/reactive/createActions.svelte.ts delete mode 100644 packages/svelte-querybuilder/src/lib/reactive/createActions.test.ts delete mode 100644 packages/svelte-querybuilder/src/lib/reactive/ruleContext.svelte.test.ts delete mode 100644 packages/svelte-querybuilder/src/lib/reactive/ruleContext.svelte.ts delete mode 100644 packages/svelte-querybuilder/src/lib/reactive/ruleGroupContext.svelte.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3202876..7ab5d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` (`{ 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 diff --git a/README.md b/README.md index 721a177..9161b08 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/bun.lock b/bun.lock index 76380ff..998a39e 100644 --- a/bun.lock +++ b/bun.lock @@ -57,7 +57,7 @@ "name": "svelte-querybuilder", "version": "0.1.1", "dependencies": { - "@react-querybuilder/core": "^8.22.3", + "@react-querybuilder/core": "https://pkg.pr.new/react-querybuilder/react-querybuilder/@react-querybuilder/core@3d5d992", }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", @@ -238,7 +238,7 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@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@3d5d992", { "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-oPO4zaREeb5gokxobHytDWwr/SCZYZGyLUdWsWAdQAjo3KWHHkA2dNxHpiclnQdQ32rNtn8GcS0msyTFTeg7wQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA=="], @@ -378,6 +378,8 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@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=="], @@ -516,7 +518,7 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - "immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], + "immer": ["immer@11.1.16", "", {}, "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q=="], "immutable": ["immutable@5.1.9", "", {}, "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg=="], diff --git a/docs/customization.md b/docs/customization.md index fa315f5..054e038 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -158,19 +158,24 @@ 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 - - + + ``` +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: diff --git a/docs/differences-from-react-querybuilder.md b/docs/differences-from-react-querybuilder.md index 4003d8a..3c757f9 100644 --- a/docs/differences-from-react-querybuilder.md +++ b/docs/differences-from-react-querybuilder.md @@ -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 - - - - - -``` +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 @@ -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: @@ -92,7 +89,7 @@ React has no equivalent; `controlElements` is its only component-level customiza - `ReactNode` → `LabelNode` (`Snippet | string`). - `ComponentType

` → Svelte's `Component

`. -- `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. @@ -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. diff --git a/packages/svelte-querybuilder/package.json b/packages/svelte-querybuilder/package.json index e29ec25..cd19268 100644 --- a/packages/svelte-querybuilder/package.json +++ b/packages/svelte-querybuilder/package.json @@ -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" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", diff --git a/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte b/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte index 8179da1..a660f8f 100644 --- a/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte +++ b/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte @@ -70,7 +70,7 @@ level={0} /> {#if requiresThreshold(props.match.mode)} [ ]; /** - * Structural options reach the manager through `reconfigure`, so a changed prop updates the - * option lists in place without discarding the query, the undo/redo history, or subscribers. + * Configuration is `$derived` from props, so a changed prop invalidates exactly what depended on + * it — the option lists — while leaving the query and the undo/redo history alone. Nothing is + * re-applied anywhere, which is what these assertions are really pinning down. */ -describe('QueryBuilder reconfiguration', () => { +describe('QueryBuilder prop changes', () => { it('updates the field selector when `fields` changes', async () => { const { rerender } = render(QueryBuilder, { props: { fields, defaultQuery: query } }); @@ -196,17 +197,6 @@ describe('QueryBuilder reconfiguration', () => { expect(onQueryChange).not.toHaveBeenCalled(); }); - it('never reconfigures an externally supplied manager', async () => { - const manager = new QueryManager(query, { fields }); - const reconfigure = vi.spyOn(manager, 'reconfigure'); - const { rerender } = render(QueryBuilder, { props: { fields, manager } }); - - await rerender({ fields: altFields, manager }); - - expect(reconfigure).not.toHaveBeenCalled(); - expect(optionValues(screen.getByTestId(TestID.fields))).toEqual(['firstName', 'lastName']); - }); - it('updates a subquery builder when `subproperties` change', async () => { const subQuery: RuleGroupType = { id: 'sg', diff --git a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte index 3308273..46c61b3 100644 --- a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte +++ b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.svelte @@ -2,15 +2,14 @@ @component The query builder. - Port of React Query Builder's `QueryBuilder`/`QueryBuilderInternal`. All state lives in a - `QueryManager` (see `createQueryBuilderState`). + Port of React Query Builder's `QueryBuilder`/`QueryBuilderInternal`. All state lives in runes + (see `createQueryBuilderState`). The query can be driven three ways: - `bind:query` — two-way binding. - `query` + `onQueryChange` — controlled. - - `defaultQuery` — uncontrolled. - - a `manager` prop — driven from outside the component tree entirely. + - `defaultQuery`, or nothing at all — uncontrolled. --> diff --git a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts index 2df531e..8205be9 100644 --- a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts +++ b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.test.ts @@ -99,7 +99,7 @@ describe('QueryBuilder', () => { await userEvent.click(screen.getByTestId(TestID.addRule)); expect(screen.getAllByTestId(TestID.rule)).toHaveLength(3); - // Once on mount (`enableMountQueryChange` defaults to `true`), once for the addition. + // Once for the query this builder seeded itself, once for the addition. expect(onQueryChange).toHaveBeenCalledTimes(2); expect(onQueryChange.mock.lastCall![0].rules).toHaveLength(3); }); @@ -292,20 +292,6 @@ describe('QueryBuilder', () => { expect(screen.getAllByTestId(TestID.rule)).toHaveLength(1); }); - it('is driven by an external manager', async () => { - const { QueryManager } = await import('@react-querybuilder/core'); - const manager = new QueryManager(flatQuery, { fields }); - - render(QueryBuilder, { props: { fields, manager } }); - expect(screen.getAllByTestId(TestID.rule)).toHaveLength(2); - - manager.remove([0]); - await vi.waitFor(() => expect(screen.getAllByTestId(TestID.rule)).toHaveLength(1)); - - await userEvent.click(screen.getByTestId(TestID.addRule)); - expect(manager.getQuery().rules).toHaveLength(2); - }); - it('renders independent combinators inline rather than in the group header', () => { render(QueryBuilder, { props: { diff --git a/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte b/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte index 53bc739..9d00f50 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte @@ -13,7 +13,6 @@ ``` -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. @@ -166,8 +164,10 @@ Hold the query yourself and bind it. There is no `manager` prop — query state let query = $state({ combinator: 'and', rules: [] }); - // Core's pure query tools are re-exported from the barrel. - const addRule = () => (query = add(query, { field: 'firstName', operator: '=', value: '' }, [])); + // 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 })); diff --git a/packages/svelte-querybuilder/src/lib/components/BindQueryHarness.test.svelte b/packages/svelte-querybuilder/src/lib/components/BindQueryHarness.test.svelte new file mode 100644 index 0000000..aa5b8f8 --- /dev/null +++ b/packages/svelte-querybuilder/src/lib/components/BindQueryHarness.test.svelte @@ -0,0 +1,23 @@ + + + + diff --git a/packages/svelte-querybuilder/src/lib/components/QueryBuilder.bind.test.ts b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.bind.test.ts new file mode 100644 index 0000000..9c189bc --- /dev/null +++ b/packages/svelte-querybuilder/src/lib/components/QueryBuilder.bind.test.ts @@ -0,0 +1,45 @@ +/** + * `bind:query` at the component level: the parent owns the query, so what it ends up holding — + * including the query the builder seeds for itself when the parent starts empty — is the whole + * contract. + */ + +import type { FullField, RuleGroupType } from '@react-querybuilder/core'; +import { TestID } from '@react-querybuilder/core'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Harness from './BindQueryHarness.test.svelte'; + +const fields: FullField[] = [ + { name: 'firstName', label: 'First Name', value: 'firstName' }, + { name: 'lastName', label: 'Last Name', value: 'lastName' }, +]; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('QueryBuilder bind:query', () => { + it('writes the seeded query back to the parent', () => { + const report = vi.fn(); + // `state_unsafe_mutation` would mean the seeding write happens inside a derivation. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + render(Harness, { props: { fields, report } }); + + const bound = report.mock.lastCall![0] as RuleGroupType; + expect(bound).toMatchObject({ combinator: 'and', rules: [] }); + expect(bound.id).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('writes later commits back to the parent', async () => { + const report = vi.fn(); + render(Harness, { props: { fields, report } }); + + await userEvent.click(screen.getByTestId(TestID.addRule)); + + expect((report.mock.lastCall![0] as RuleGroupType).rules).toHaveLength(1); + }); +}); diff --git a/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte b/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte index 9d00f50..5ad0db3 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.svelte @@ -41,13 +41,13 @@ }).optionList ); - // The rule's `value` is the subquery. Until it is a prepared group, leave `query` undefined - // and let the subquery seed itself: a seeded query is emitted once during initialization, - // which writes it back through `onChangeValue`. + // The rule's `value` is the subquery. Anything but a group leaves `query` undefined and lets + // the subquery seed itself; a seeded query is emitted once during initialization, which writes + // it back through `onChangeValue`. A group without an `id` is passed through all the same — + // query-state initialization prepares it and emits the normalized result, so its existing + // rules survive. const subQuery = $derived( - isRuleGroup(props.rule.value) && props.rule.value.id - ? (props.rule.value as RuleGroupType) - : undefined + isRuleGroup(props.rule.value) ? (props.rule.value as RuleGroupType) : undefined ); const subQueryProps = $derived({ diff --git a/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.test.ts b/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.test.ts index 9219d37..4de3128 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.test.ts +++ b/packages/svelte-querybuilder/src/lib/components/RuleSubQuery.test.ts @@ -105,6 +105,36 @@ describe('RuleSubQuery', () => { expect(onQueryChange.mock.lastCall![0].rules[0].value).toMatchObject({ combinator: 'and' }); }); + it('preserves a populated subquery that has no id', () => { + const onQueryChange = vi.fn(); + render(QueryBuilder, { + props: { + fields, + defaultQuery: { + combinator: 'and', + rules: [ + { + id: 'r1', + field: 'tags', + operator: '=', + match: { mode: 'all' }, + value: { combinator: 'or', rules: [{ field: 'name', operator: '=', value: 'x' }] }, + }, + ], + } satisfies RuleGroupType, + onQueryChange, + }, + }); + + // The id-less group is prepared, not replaced: its rules survive and get ids. + const value = onQueryChange.mock.lastCall![0].rules[0].value; + expect(value.combinator).toBe('or'); + expect(value.rules).toHaveLength(1); + expect(value.rules[0]).toMatchObject({ field: 'name', operator: '=', value: 'x' }); + expect(value.rules[0].id).toBeDefined(); + expect(screen.getAllByTestId(TestID.rule)).toHaveLength(2); + }); + it('disables the subquery along with the rule', () => { render(QueryBuilder, { props: { fields, defaultQuery: query, disabled: true } }); diff --git a/packages/svelte-querybuilder/test/conformance/actions.svelte.test.ts b/packages/svelte-querybuilder/test/conformance/actions.svelte.test.ts index 74f61e4..96c8010 100644 --- a/packages/svelte-querybuilder/test/conformance/actions.svelte.test.ts +++ b/packages/svelte-querybuilder/test/conformance/actions.svelte.test.ts @@ -62,8 +62,13 @@ const eligible = fixture.cases.filter( const valueSources: ValueSourceFullOptions = [{ name: 'value', value: 'value', label: 'Value' }]; const propsFor = (options: RunOptions): Partial => ({ - ...(options.queryDisabled ? { disabled: true } : {}), - ...(options.disabledPaths ? { disabled: options.disabledPaths } : {}), + // Core checks `queryDisabled` before `disabledPaths`, so the whole-query flag wins when a case + // sets both. Spreading both would silently let `disabledPaths` overwrite `disabled: true`. + ...(options.queryDisabled + ? { disabled: true } + : options.disabledPaths + ? { disabled: options.disabledPaths } + : {}), ...(options.maxLevels === undefined ? {} : { maxLevels: options.maxLevels }), // The prop-level equivalents of `replay.ts`'s `updateResolvers`. getDefaultOperator: '=',