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
64 changes: 47 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ This guide covers `svelte-querybuilder` development: code style, workflow, and o

## Project Overview

Svelte 5 port of [React Query Builder](https://react-querybuilder.js.org). Bun workspace monorepo:
A Svelte 5 library that shares its logic layer with [React Query Builder](https://react-querybuilder.js.org) — not a transliteration of it. Behavior and rendered DOM are held to RQB; the component API is designed for Svelte. Bun workspace monorepo:

- **Main package**: `packages/svelte-querybuilder` - Svelte 5 components + types
- **Main package**: `packages/svelte-querybuilder` - Svelte 5 components + types, plus the development playground under `src/routes`
- **Logic layer**: `@react-querybuilder/core` (npm dependency) - query manipulation, parsers, formatters, defaults, i18n strings. **Not vendored.** Re-exported from the barrel so consumers never need a direct core dependency.
- **Examples**: `examples/*` (workspace glob; may be empty)
- **Examples**: `examples/*` (workspace glob) - currently just `sveltekit`, which doubles as the SSR gate

See `CHANGELOG.md` for release history.

Expand Down Expand Up @@ -49,10 +49,12 @@ Run from repo root unless noted.
- `bun run check:exports` - `attw` on a packed tarball + `dist` specifier lint (needs a build first)
- `bun lint` - oxlint
- `bun fmt` / `bun fmt:check` - oxfmt (run `bun fmt` after changes)
- `bun run conformance` - Fetches the RQB fixture set and runs the conformance suites

**Build:**
**Build and run:**

- `bun run build` - `svelte-package` into `dist`, then compile SCSS
- `bun run dev` - Serves the playground at `packages/svelte-querybuilder/src/routes` against library source

Before submitting a PR, run the CI sequence: `bun run check:all`.

Expand All @@ -61,14 +63,23 @@ Before submitting a PR, run the CI sequence: `bun run check:all`.
### Structure

```
packages/svelte-querybuilder/src/lib/
├── *.svelte # Components (PascalCase.svelte)
├── types/ # TypeScript defs
├── utils/ # Svelte-specific utilities (camelCase.ts)
├── styles/ # SCSS (_svelte.scss layered over core's partials)
└── index.ts # Barrel: components, types, and `export * from '@react-querybuilder/core'`
packages/svelte-querybuilder/
├── src/lib/ # The only published code
│ ├── *.svelte # Components (PascalCase.svelte)
│ ├── types/ # TypeScript defs
│ ├── utils/ # Svelte-specific utilities (camelCase.ts)
│ ├── styles/ # SCSS (_svelte.scss layered over core's partials)
│ └── index.ts # Barrel: components, types, `export * from '@react-querybuilder/core'`
├── src/routes/ # SvelteKit dev playground; never packaged
└── test/conformance/ # Fixture-driven parity suites (fixtures are downloaded, not generated)
```

The package is a SvelteKit project so that `src/routes` can exist, but Kit is a development
convenience only: `svelte-package` reads `src/lib` and nothing else. `vite.config.ts` serves the
playground; the unit suite has its own `vitest.config.ts` on the bare `svelte()` plugin, because
`sveltekit()` resolves its project from the working directory and Vitest runs the package from
the monorepo root.

### Naming

- **Components**: PascalCase (`QueryBuilder.svelte`, `RuleGroup.svelte`)
Expand All @@ -82,7 +93,7 @@ Runes only. No Svelte 4 idioms — no `export let`, no `$:`, no stores for compo

```svelte
<script lang="ts">
import type { QueryBuilderProps } from './types';
import type { QueryBuilderProps } from './types/index.js';

let { fields, query = $bindable(), onQueryChange }: QueryBuilderProps = $props();

Expand All @@ -97,9 +108,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<F>` for the name.
- Always `import type` for type-only imports
- Re-export core types from the barrel rather than redefining them

Expand Down Expand Up @@ -137,16 +157,23 @@ Only fall back to `node:*` APIs when no Bun equivalent exists. Library code unde
- Keep `data-testid` attributes matching RQB's so ported tests stay recognizable
- Coverage threshold is 80% lines and should trend up, not down
- SSR must not break: components have to render without `window` (`bun run test:ssr`)
- Conformance (`bun run conformance`) compares the rendered DOM to fixtures recorded from the React package: every element's `class`, each rule group's accessible description, each element's _own_ text verbatim, and the result of curated mutation sequences. Fixtures are downloaded from a pinned upstream tag and are gitignored — never regenerate them locally, which would make the suite tautological.

## Porting from React Query Builder

The React source is the spec. When porting a component:
RQB is the spec for **behavior and rendered output**, not for the API. Two contracts, held to different standards:

**Kept, deliberately.** Class names, `data-testid`s, `data-path`, element structure and document order, and each element's own text. RQB stylesheets and themes have to port over unchanged, and the conformance fixtures are only meaningful because the DOM matches. Diverging here needs a reason and a changelog note.

**Ours to design.** Prop names, control maps, lifecycle flags, generic parameters, and the shape of anything a consumer configures. API parity is _not_ a goal: where RQB's surface exists to work around React — `controlElements` nesting, `enableMountQueryChange`, `useRule`-style hook returns, a `qbId` registry — build the Svelte-shaped equivalent instead of transliterating. Prefer the form a Svelte consumer would expect (top-level snippet props, `bind:`, callback props, runes) over the form that would make a diff against RQB smaller.

When porting a component:

1. Read the RQB source and its tests
2. Keep prop names, class names, `data-testid`s, and DOM structure identical unless there's a reason not to
2. Reproduce the DOM exactly; design the props for Svelte
3. Translate hooks to runes; drop memoization
4. Port the tests, then the component
5. Note intentional divergences in a comment and in the changelog
5. Note intentional divergences in a comment, in the changelog, and in `docs/differences-from-react-querybuilder.md`

Document user-visible changes in `CHANGELOG.md` under `## [Unreleased]` (Keep a Changelog format, SemVer).

Expand Down Expand Up @@ -175,7 +202,8 @@ Use core's `Translations` type and default strings. Svelte-side: allow snippets
10. Diverging from RQB class names / `data-testid`s without a reason
11. Missing tests or accessibility coverage
12. Editing `packages/svelte-querybuilder/dist` (generated by `svelte-package`)
13. Extensionless or directory-style relative imports. `svelte-package` does not rewrite specifiers, so `./foo` and `./types` break Node ESM consumers. Write `./foo.js` and `./types/index.js`; a `*.svelte.ts` rune module is `*.svelte.js`, a component is `*.svelte`. `bun run check:exports` enforces this.
13. Removing the `<!-- -->` joiners between sibling elements in `RuleComponents`, `RuleGroupHeader`, `RuleGroupBody`, `RuleGroup`, `MatchModeEditor`, `ShiftActions`, or `ValueEditor`. JSX drops whitespace-only lines between elements; Svelte collapses each gap to a single space and _keeps_ it, which would add text nodes RQB never emits. Conformance compares element text verbatim.
14. Extensionless or directory-style relative imports. `svelte-package` does not rewrite specifiers, so `./foo` and `./types` break Node ESM consumers. Write `./foo.js` and `./types/index.js`; a `*.svelte.ts` rune module is `*.svelte.js`, a component is `*.svelte`. `bun run check:exports` enforces this.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Quick Reference

Expand All @@ -187,9 +215,11 @@ Use core's `Translations` type and default strings. Svelte-side: allow snippets
- `bun run test` - Tests
- `bun fmt` - Format
- `bun run test:coverage` - Coverage
- `bun run dev` - Playground

**Directories:**

- `packages/svelte-querybuilder/src/lib/` - Library source (the only published code)
- `packages/svelte-querybuilder/src/routes/` - Dev playground; never published
- `packages/svelte-querybuilder/dist/` - Generated; never edit
- `examples/` - Demos and starter templates
- `examples/sveltekit/` - Starter template and the SSR gate
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ Control elements are now composed the Svelte way. Each of the 24 control names i
- **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`.
- The `examples/demo` package. Its content became the development playground inside the library package (see below); `examples/sveltekit` remains as the starter template and SSR gate.

### 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 }`.
- A SvelteKit development playground at `packages/svelte-querybuilder/src/routes`, run with `bun run dev`. It imports library source (`$lib`) and core's SCSS rather than anything in `dist`, so component _and_ stylesheet edits hot-reload without a build. The package is now a SvelteKit project for this reason alone — `svelte-package` still reads `src/lib` and nothing else, and nothing under `src/routes` is published.

### Changed

Expand All @@ -40,12 +42,18 @@ Control elements are now composed the Svelte way. Each of the 24 control names i
- `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.
- The conformance extractor records each element's _own_ direct text-node children verbatim, matching the `text` channel added by upstream fixture `schemaVersion` 3. Fixtures older than that do not record it, so the channel is dropped before comparison until `CONFORMANCE_TAG` is bumped to a release that publishes schema 3; both versions are accepted by the fetch script in the meantime.
- The unit suite has its own `vitest.config.ts`. `vite.config.ts` now carries the SvelteKit plugin for the playground, and Kit resolves its project from the working directory — which is the monorepo root when Vitest runs the package as a `projects` entry.
- 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<F>` and `RuleGroupProps<F>` 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<F>`. `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<FullField, string>`, 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`.

### Fixed

- No element renders a whitespace text node React Query Builder does not. Svelte collapses the gap between two sibling elements to a single space and keeps it, where JSX drops whitespace-only lines entirely, so a rule `<div>` was carrying ten stray text nodes and a group body up to eight. Every affected sibling pair is now joined with an `<!-- -->` comment. Invisible in any normalizing assertion, but it is real DOM: text nodes affect `childNodes`, `::first-child`-adjacent CSS, and anything walking the tree.
- 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
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# svelte-querybuilder

A Svelte 5 port of [React Query Builder](https://react-querybuilder.js.org). Builds a nested query structure from a field/operator/value UI, backed by [`@react-querybuilder/core`](https://www.npmjs.com/package/@react-querybuilder/core).
A Svelte 5 query builder: a nested query structure built from a field/operator/value UI, backed by [`@react-querybuilder/core`](https://www.npmjs.com/package/@react-querybuilder/core) — the same logic layer [React Query Builder](https://react-querybuilder.js.org) runs on, so query shapes, formatters, and parsers behave identically and the rendered DOM is class-compatible. The component API is Svelte's own.

## Install

Expand Down Expand Up @@ -61,16 +61,19 @@ The DOM is class-compatible with React Query Builder, so existing RQB stylesheet

## Docs

- [Differences from React Query Builder](./docs/differences-from-react-querybuilder.md)
- [Customization](./docs/customization.md)
**Coming from React Query Builder?** Read [Differences from React Query Builder](./docs/differences-from-react-querybuilder.md) first. Your queries, field configuration, and CSS carry over unchanged; the component API is Svelte's, not React's.

- [Differences from React Query Builder](./docs/differences-from-react-querybuilder.md) — start here if you know RQB
- [Customization](./docs/customization.md) — snippets, `controls`, translations, context
- [Styling](./docs/styling.md)
- Concepts, field/operator configuration, query formats, and parsers: the [React Query Builder documentation](https://react-querybuilder.js.org/docs/intro) applies directly, since the logic layer is shared.

## Examples

- [`examples/demo`](./examples/demo) — Vite + Svelte, running against library source. `bun run --filter @svelte-querybuilder/example-demo dev`
- [`examples/sveltekit`](./examples/sveltekit) — SvelteKit, server-side rendering. Doubles as the repo's SSR gate (`bun run test:ssr`).

The development playground lives in the library package itself (`packages/svelte-querybuilder/src/routes`) and runs against library source: `bun run dev`.

## Non-goals

Not in v1, and not planned for the near term:
Expand Down
16 changes: 3 additions & 13 deletions bun.lock

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

Loading