From 79a98cb44e7845e8747c5134aea5721b7270b756 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Thu, 13 Aug 2026 15:55:45 -0700 Subject: [PATCH 1/3] Component-level cleanup --- AGENTS.md | 11 +- CHANGELOG.md | 3 + .../src/lib/components/Rule.svelte | 5 +- .../src/lib/components/RuleComponents.svelte | 108 ++++++++++-------- .../src/lib/components/RuleGroup.svelte | 4 +- .../src/lib/components/RuleSubQuery.svelte | 5 +- .../src/lib/components/ValueEditor.svelte | 4 +- .../src/lib/types/controls.ts | 2 +- .../src/lib/types/props.ts | 18 ++- .../src/lib/types/types.test-d.ts | 8 ++ 10 files changed, 103 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebfe854..488af70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,18 @@ Runes only. No Svelte 4 idioms — no `export let`, no `$:`, no stores for compo - `{#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 +#### Destructuring `$props()` + +Two conventions, and the choice is not stylistic: + +- **Leaf controls destructure**: `const { value, handleOnChange }: ValueSelectorProps = $props();`. They read their props during render, so the destructured snapshot is what the template already tracks. +- **Forwarding components don't**: `const props: RuleProps = $props();`, then `props.schema` at the point of use. Destructuring reads every prop eagerly, at init; components that hand props onward (or build getter-backed prop bags — see `internal/lazyProps.ts`) must read them late so each downstream consumer subscribes only to what it actually touches. + +Don't name a local `props` in a component that also destructures `$props()` — svelte2tsx generates a conflicting binding and `svelte-check` fails with "`$props` used before its declaration." Name it for what it holds (`ruleProps`). + ### TypeScript -- Generics with constraints, mirroring RQB's `RG extends RuleGroupTypeAny`, `F extends FullField`, etc. +- Generics with constraints, mirroring RQB's `RG extends RuleGroupTypeAny`, `F extends FullField`, etc. `F` is always the _field object_ type — including in `RuleProps`, where RQB parameterizes by field _name_ instead. Use `GetOptionIdentifierType` for the name. - Always `import type` for type-only imports - Re-export core types from the barrel rather than redefining them diff --git a/CHANGELOG.md b/CHANGELOG.md index bce837a..f7f7670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ Control elements are now composed the Svelte way. Each of the 24 control names i - 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. +- **Breaking:** `RuleProps` and `RuleGroupProps` are parameterized by the _field object_ type, like every other props interface in this package. `RuleProps` previously took the field _name_ (`F extends string`, RQB's convention for that one interface) and `RuleGroupProps` took `F extends FullOption`; both are now `F extends FullField`, with the field name obtained via `GetOptionIdentifierType`. `CommonSubComponentProps` and `SelectorOrEditorProps` are likewise constrained to `FullField` rather than `FullOption`. +- `Rule`, `RuleGroup`, and `ValueEditor` are generic over the same `F`/`O`. `ValueEditor` previously hardcoded `ValueEditorProps`, so a replacement value editor was better typed than the built-in one. +- `RuleComponents` takes `mode`, `rule: { props, parts }`, and (in `subQuery` mode) `subQuery: { props, parts }`, replacing four flat props. The two `(props, parts)` pairs — the rule's, and the subquery's own query-builder state — are now visibly paired, and whether a rule renders a `matchModeEditor` is decided by the explicit `mode` discriminator rather than by the presence of the subquery state. - `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`. diff --git a/packages/svelte-querybuilder/src/lib/components/Rule.svelte b/packages/svelte-querybuilder/src/lib/components/Rule.svelte index 6eba0bb..1014c93 100644 --- a/packages/svelte-querybuilder/src/lib/components/Rule.svelte +++ b/packages/svelte-querybuilder/src/lib/components/Rule.svelte @@ -5,7 +5,8 @@ Port of React Query Builder's `Rule`. A thin wrapper around `RuleComponents.svelte`, or around `RuleSubQuery.svelte` when the rule's field supports match modes. --> - - + diff --git a/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte b/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte index 38930c8..e900ca5 100644 --- a/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte +++ b/packages/svelte-querybuilder/src/lib/components/ValueEditor.svelte @@ -5,7 +5,7 @@ Port of React Query Builder's `ValueEditor`. The reset effect lives in `createValueEditorReset` (the one piece with a timing hazard); the rest is derived here. --> - + +
%sveltekit.body%
diff --git a/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte b/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte index 0ce362e..31c6449 100644 --- a/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte +++ b/packages/svelte-querybuilder/src/lib/components/MatchModeEditor.svelte @@ -67,8 +67,8 @@ listsAsArrays: false, path: dummyPath, level: 0, - }} /> -{#if requiresThreshold(props.match.mode)} + }} />{#if requiresThreshold(props.match.mode)} `. - - Port of React Query Builder's `RuleComponents`. In `subQuery` mode (used by - `RuleSubQuery.svelte`) the subquery's group header and body are rendered in `
`s around - the rule's own action buttons, which is why one instance has to hold two `(props, parts)` - pairs from two separate query-builder states. ---> +` +joiners between siblings are load-bearing, here and in every other component whose output lands +inside a rule or group element. JSX drops whitespace-only lines between elements; Svelte collapses +each gap to a single space and keeps it, which would put text nodes in the DOM that React Query +Builder never emits. The conformance fixtures compare each element's own text verbatim, so the +difference is a failure, not a nicety. Do not reformat these apart. --> + {#if schema.showShiftActions} -{/if} -{#if parts.showFieldSelector} +{/if}{#if parts.showFieldSelector} -{/if} -{#if schema.autoSelectField || ruleObj.field !== translations.fields.placeholderName} +{/if}{#if schema.autoSelectField || ruleObj.field !== translations.fields.placeholderName} {#if mode === 'subQuery'} {:else} - - {#if parts.showValueControls} + {#if parts.showValueControls} {#if parts.showValueSourceSelector} - {/if} - + {/if} {/if} {/if} -{/if} -{#if subQuery} +{/if}{#if subQuery}
-{/if} -{#if schema.showCloneButtons} +{/if}{#if schema.showCloneButtons} -{/if} -{#if schema.showLockButtons} +{/if}{#if schema.showLockButtons} -{/if} -{#if schema.showMuteButtons} +{/if}{#if schema.showMuteButtons} -{/if} - -{#if subQuery} +{/if}{#if subQuery}
diff --git a/packages/svelte-querybuilder/src/lib/components/RuleGroup.svelte b/packages/svelte-querybuilder/src/lib/components/RuleGroup.svelte index c0612f6..67a999f 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleGroup.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleGroup.svelte @@ -33,6 +33,8 @@
+
diff --git a/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte b/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte index 4f50663..79f9cd3 100644 --- a/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte +++ b/packages/svelte-querybuilder/src/lib/components/RuleGroupBody.svelte @@ -85,8 +85,8 @@ return parts.disabled; }, })} /> - {/if} - {#if typeof r === 'string'} + {/if}{#if typeof r === 'string'} `. - - Port of React Query Builder's `RuleGroupHeaderComponents`. Internal rather than a control - element; it is a separate component only so that `Rule` can reuse it for a subquery. ---> +` +joiners between siblings suppress the whitespace text nodes Svelte would otherwise emit between them +— see `RuleComponents.svelte` for why that matters. --> + {#if schema.showShiftActions && path.length > 0} -{/if} -{#if !schema.showCombinatorsBetweenRules && !schema.independentCombinators} +{/if}{#if !schema.showCombinatorsBetweenRules && !schema.independentCombinators} -{/if} -{#if schema.showNotToggle} +{/if}{#if schema.showNotToggle} -{/if} - -{#if schema.maxLevels > path.length} +{/if}{#if schema.maxLevels > path.length} -{/if} -{#if schema.showCloneButtons && path.length > 0} +{/if}{#if schema.showCloneButtons && path.length > 0} -{/if} -{#if schema.showLockButtons} +{/if}{#if schema.showLockButtons} -{/if} -{#if schema.showMuteButtons} +{/if}{#if schema.showMuteButtons} -{/if} -{#if schema.showUndoRedo && path.length === 0} +{/if}{#if schema.showUndoRedo && path.length === 0} -{/if} -{#if path.length > 0} +{/if}{#if path.length > 0} {/if} diff --git a/packages/svelte-querybuilder/src/lib/components/ShiftActions.svelte b/packages/svelte-querybuilder/src/lib/components/ShiftActions.svelte index dd4907e..80c994e 100644 --- a/packages/svelte-querybuilder/src/lib/components/ShiftActions.svelte +++ b/packages/svelte-querybuilder/src/lib/components/ShiftActions.svelte @@ -26,8 +26,9 @@ type="button" disabled={disabled || shiftUpDisabled} onclick={e => shiftUp?.(e)} - title={titles?.shiftUp}>