COMMUNICATION STYLE: Be aggressively concise. Prioritize brevity over grammar. Examples:
- "Build failed" not "The build has failed"
- "Fixed type error" not "I have fixed the type error"
- "Run tests" not "I will run the tests for you"
This guide covers svelte-querybuilder development: code style, workflow, and other patterns.
A Svelte 5 library that shares its logic layer with React Query Builder — 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, plus the development playground undersrc/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) - currently justsveltekit, which doubles as the SSR gate
See CHANGELOG.md for release history.
Core owns the logic; this package owns the UI. Before writing an algorithm, check whether core already exports it (add, remove, update, move, formatQuery, parseSQL, defaultOperators, transformQuery, etc.). Port React behavior, not React implementation. Hooks, memoization, context, and Redux are React solutions to React problems — use Svelte 5 runes instead.
Drag-and-drop, UI-framework packages (Ant/Bootstrap/MUI/etc.), expr/datetime integrations, async option lists, a Redux store or qbId registry, deprecated-prop fallbacks from the React package. Don't add these speculatively.
bun install
bun run buildRun from repo root unless noted.
Quality:
bun run test- Vitest (jsdom +@testing-library/svelte)bun run test:watch- Watch modebun run test:coverage- Coverage (threshold: 80% lines,src/lib/**)bun run test:ssr- SSR smoke testbun run check-svelte-check(types + Svelte diagnostics; this repo'stypecheck)bun run check:exports-attwon a packed tarball +distspecifier lint (needs a build first)bun lint- oxlintbun fmt/bun fmt:check- oxfmt (runbun fmtafter changes)bun run conformance- Fetches the RQB fixture set and runs the conformance suites
Build and run:
bun run build-svelte-packageintodist, then compile SCSSbun run dev- Serves the playground atpackages/svelte-querybuilder/src/routesagainst library source
Before submitting a PR, run the CI sequence: bun run check:all.
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.
- Components: PascalCase (
QueryBuilder.svelte,RuleGroup.svelte) - Utilities: camelCase (
generateId.ts) - Types: camelCase files (
basic.ts) - Tests:
*.test.tscolocated next to the source
Runes only. No Svelte 4 idioms — no export let, no $:, no stores for component state, no createEventDispatcher.
<script lang="ts">
import type { QueryBuilderProps } from './types/index.js';
let { fields, query = $bindable(), onQueryChange }: QueryBuilderProps = $props();
const operators = $derived(getOperators(field));
</script>$props()with a typed destructure;$bindable()for two-wayquery$derived/$derived.byinstead ofuseMemo. Fine-grained reactivity means manual memoization is almost never needed — don't port React's memoization.$statefor local mutable state;$effectonly as a last resort (prefer$derived)- Callback props (
onQueryChange), not events {#snippet}/{@render}for slot-like customization: each control is a top-level snippet prop, with thecontrolsobject 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 }(seeinternal/Control.svelte) — never invoke a compiled component or snippet by handsetContext/getContextfor cross-tree config instead of prop drilling — but context is set once at init, so pass a getter or a$stateobject if the value must stay reactive
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();, thenprops.schemaat the point of use. Destructuring reads every prop eagerly, at init; components that hand props onward (or build getter-backed prop bags — seeinternal/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).
- Generics with constraints, mirroring RQB's
RG extends RuleGroupTypeAny,F extends FullField, etc.Fis always the field object type — including inRuleProps, where RQB parameterizes by field name instead. UseGetOptionIdentifierType<F>for the name. - Always
import typefor type-only imports - Re-export core types from the barrel rather than redefining them
Query updates are immutable and path-based ([0, 1, 2]), same as RQB. Route them through core's add/remove/update/move and assign the result. Never mutate a query node in place, even though runes would make it "work" — consumers hold references to the previous query.
- SCSS with core's class names (
.queryBuilder-rule, etc.) — the DOM should be class-compatible with RQB so existing stylesheets and custom themes port over src/lib/styles/_svelte.scssholds package-local overrides;build:csscopies core's.scsspartials in before compiling- No CSS-in-JS, no scoped styles that break consumer overrides
oxfmt config: 100 cols, 2 spaces, single quotes, semicolons, ES5 trailing commas, arrowParens: avoid, bracketSameLine: true, sorted imports. Don't hand-format; run bun fmt.
This project runs on Bun. Prefer Bun-native APIs over Node.js equivalents in scripts and tooling:
Bun.file(path).text()/.json()instead offs.readFileSyncBun.write(path, content)instead offs.writeFileSyncBun.spawnSync(...)/Bun.spawn(...)instead ofchild_process.execSync/exec
Only fall back to node:* APIs when no Bun equivalent exists. Library code under src/lib must stay runtime-agnostic — no Bun or Node APIs there.
- Vitest +
@testing-library/svelte+@testing-library/jest-dom+vitest-axe - jsdom environment, globals enabled, setup in
packages/svelte-querybuilder/vitest-setup.ts - Test files:
ComponentName.test.ts, colocated - Describe blocks: component/function name. Test cases: descriptive behavior.
- Test behavior through the DOM and
userEvent, not internal state - Keep
data-testidattributes 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'sclass, 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.
RQB is the spec for behavior and rendered output, not for the API. Two contracts, held to different standards:
Kept, deliberately. Class names, data-testids, 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:
- Read the RQB source and its tests
- Reproduce the DOM exactly; design the props for Svelte
- Translate hooks to runes; drop memoization
- Port the tests, then the component
- 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).
- ARIA attributes and label associations preserved from RQB
- Keyboard navigation for all controls
vitest-axeassertions on rendered outputdata-testidattributes on every control element
Use core's Translations type and default strings. Svelte-side: allow snippets as well as strings for translatable labels.
- Reimplementing logic that
@react-querybuilder/corealready exports - Svelte 4 idioms (
export let,$:,createEventDispatcher) - Porting React memoization into runes
- Mutating query objects in place
- Missing
import type $effectwhere$derivedwould do- Non-reactive values captured in
setContext - Direct DOM manipulation
- Breaking SSR
- Diverging from RQB class names /
data-testids without a reason - Missing tests or accessibility coverage
- Editing
packages/svelte-querybuilder/dist(generated bysvelte-package) - Removing the
<!-- -->joiners between sibling elements inRuleComponents,RuleGroupHeader,RuleGroupBody,RuleGroup,MatchModeEditor,ShiftActions, orValueEditor. 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. - Extensionless or directory-style relative imports.
svelte-packagedoes not rewrite specifiers, so./fooand./typesbreak Node ESM consumers. Write./foo.jsand./types/index.js; a*.svelte.tsrune module is*.svelte.js, a component is*.svelte.bun run check:exportsenforces this.
Commands:
bun run build- Package + CSSbun run check- svelte-checkbun run check:exports- Package exports/resolution gatebun run test- Testsbun fmt- Formatbun run test:coverage- Coveragebun run dev- Playground
Directories:
packages/svelte-querybuilder/src/lib/- Library source (the only published code)packages/svelte-querybuilder/src/routes/- Dev playground; never publishedpackages/svelte-querybuilder/dist/- Generated; never editexamples/sveltekit/- Starter template and the SSR gate