COMMUNICATION STYLE: Be aggressively concise. Prioritize brevity over grammar.
The package solid-querybuilder is a Solid 2.0 port of
React Query Builder, built on the published
@react-querybuilder/core. The port's defining constraint is full DOM parity: tag name,
document order, data-testid, data-path, and byte-identical class attributes must match
React Query Builder's output for all conformance cases.
Blueprints: svelte-querybuilder@0.1.1 and @react-querybuilder/vue@0.2.0.
Deviate only where Solid idiom demands.
solid-querybuilder/
├── packages/solid-querybuilder/ # the library
│ ├── src/ # components, reactive layer, types, styles
│ ├── test/conformance/ # DOM-parity harness (fixtures gitignored)
│ └── scripts/ # build/check/ssr-smoke helpers
└── examples/
├── demo/ # Vite + Solid 2, aliased to the library's src
└── ssr/ # hand-rolled Vite SSR consumer; the SSR gate
Target is Solid 2 only. Peers are solid-js@^2.0.0-beta.32 and
@solidjs/web@^2.0.0-beta.32 — in Solid 2 the DOM runtime is its own package. There is no
^1.9 leg anywhere: in the manifest, in CI, or in the source. A v1-target port, if it ever
happens, is a separate repo publishing as @react-querybuilder/solid1; do not add compatibility
shims or solid-js@1 code paths here.
Wiring strategy is hybrid: QueryManager owns every write (history, guards, reconfigure);
an internal store mirror (reconciled by id) is the read path. See
~/git/SOLID_QB_PLAN.md for the full rationale; this file only records the standing rules.
bun installbun run build— vite lib build (dom), thentsc --jsx preserve(source), then types, then cssbun run test/bun run test:coverage— Vitest. Neverbun test; that is Bun's builtin runner and bypasses Vitest entirely.bun run conformance— fetch fixtures, then run the DOM-parity suitesbun run check:versions— asserts the resolved prerelease toolchain has not drifted; runs first in CIbun run test:ssr— two halves, in sequence. Firstpackages/solid-querybuilder'sscripts/ssr-smoke.ts(export-condition order in isolation, plus a markup assertion). Thenexamples/ssr'sssr-smoke-test.ts(builds the example against the publisheddist, serves it, asserts status + markup, then hydrates in jsdom). The second supersedes but does not replace the first — keep both; only the first checks the export condition in isolation.bun run check—tsc --noEmit, thencheck:examples, which fans the same out to@solid-querybuilder/example-*so an example type error breaks CIbun run lint,bun run fmt,bun run fmt:checkbun run check:all— everything CI runs
exports['.'] is the Solid triple: solid (raw JSX, resolved first) → types → import
(dom-compiled fallback). "solid" must come first in the conditions object — Node picks the
first matching key, so a solid entry that merely exists but sits after import is silently
dead. A consumer's vite-plugin-solid (or SolidStart) resolves it and compiles the raw JSX for
its own target (dom in the browser, ssr on the server). Getting the order wrong ships a
package that renders fine in the browser and silently breaks SSR/hydration.
scripts/ssr-smoke.ts is the gate, and it checks order two ways: a literal
Object.keys(exports['.'])[0] === 'solid' assertion, and Node's real resolver invoked twice
(--conditions=solid must give dist/source/index.jsx, no conditions must give dist/index.js).
A key lookup (exports['.'].solid) is order-blind and does not gate anything — do not
regress it back to that.
Keep the script even though the SolidStart gate would supersede it; it is the only thing that checks the export condition in isolation. (There is no Solid 2 SolidStart yet — the example's gate is a plain Vite SSR example — but the export condition is what that gate rests on either way.)
scripts/ssr-smoke-entry.jsx imports both @solidjs/web and the library, and is loaded
through vite.ssrLoadModule. That is load-bearing: ssr.noExternal gives Vite's module graph its
own copy of solid-js, so importing renderToString in the host process instead would render
with a different instance than the component was compiled against. Solid keeps
owner/sharedConfig state at module scope, so the copies do not share it — a trivial component
survives this, but anything using createContext/createStore/createEffect (i.e.
QueryBuilder) does not.
Conditions are the plugin's job now, not the config's. vite-plugin-solid@3 gives the ssr
environment ['solid', 'development', 'module', 'node', 'development|production'] on its own, so
the script sets no ssr.resolve.conditions. Hand-maintaining a list on top of that only
removes entries. The failure it guards against is unchanged: @solidjs/web's exports map lists
browser before node, so any condition set carrying browser hands back the browser build,
whose renderToString is a stub. Never add browser.
The entry is .jsx, not .tsx, deliberately: it stays out of the typecheck project so
bun run check does not depend on dist/ existing.
examples/demo aliases the library's source, examples/ssr consumes the built dist by
workspace specifier. That split is deliberate: the demo gives HMR without a build, and the SSR
example is the only thing in the repo that exercises the publishable artifact end to end.
- Demo alias order matters. The
solid-querybuilder/dist/*.cssalias must come before the bare-specifier alias in theresolve.aliasarray, or the bare specifier rewrites first and the CSS path is swallowed [Vue hindsight]. examples/ssrbundles the library into the server output (ssr.noExternal). Node has nosolidcondition, so an externalizedsolid-querybuilderwould resolve throughimportto the dom-compiled bundle and render nothing server-side.- The SSR server is started programmatically on an ephemeral port, never by spawning a CLI. A spawned preview leaves an orphan holding the port and serving a stale build, silently poisoning the next run.
- The hydration half runs both scripts in-process, not in jsdom.
runScripts: 'dangerously'is a dead end: jsdom's vm global trips Bun with "Proxy is not allowed in the global prototype chain", and it cannot execute thetype="module"client bundle anyway. The inlinegenerateHydrationScript()output runs throughnew Function(it assigns_$HYunqualified, so a sloppy-mode body lands it onglobalThis— do not "fix" that by copyingwindow._$HYover it, which overwrites it withundefined), and the client bundle runs throughimport()against jsdom's globals. generateHydrationScript()must be in the document. Without it the client entry dies on_$HY.donebefore it can report a mismatch, and the hydration gate passes vacuously.
src/index.tsx does export * from '@react-querybuilder/core', not just export type *. A
consumer calls formatQuery from solid-querybuilder and never depends on core directly, exactly
as React Query Builder's own barrel works. examples/ssr is what proved a gap here in review.
Must end .js, not .ts, in src/. rewriteRelativeImportExtensions is off, so tsc copies
specifiers into the emitted .d.ts verbatim. check-dist-specifiers.ts additionally allows a
./foo.js specifier in a .d.ts to resolve to a sibling foo.d.ts with no foo.js beside it (a
type-only module erased by the bundler), and allows ./foo.jsx under dist/source.
- Never destructure props.
merge/omitonly — Solid 2's replacements formergeProps/splitProps. A destructure at the top of a component silently severs reactivity and passes every type check. This is the single most likely Solid-specific defect class — check it at review of every component. ⚠️ mergetreats an explicitundefinedas a real value and overrides with it, where 1.x'smergePropsskipped it. Everymerge(defaults, props)is therefore a latent defaults-erasure bug. A missing key still falls through. Where "skip undefined" is wanted, filter explicitly, or prefer core'spreferProp/preferFlagProps.mergeis lazy (getters), not a snapshot.snapshot()— notunwrap()— before handing anything to the manager. The manager's Immer deep-freeze rejects a store proxy. Likewisesnapshot()the manager itself before reading its history (UndoRedoActions):QueryManagerkeeps history in private class fields, which aProxycannot read through.- Split effects, not
on().on()is gone;createEffect(compute, apply)makes the compute phase the dependency declaration, so the old "always useon" rule is now enforced by the API shape. Deps in compute, writes in apply.{ defer: true }survives as an option. - Apply-phase writes are legal — no
ownedWriteneeded. The owned-write rule rejects writes made while an owner is on the stack, and the apply phase is unowned. NoteownedWriteis a signal option, not an effect option;createEffecthas no such option. ⚠️ The body ofcreateRoot(fn)IS an owned scope. A write there throwsREACTIVE_WRITE_IN_OWNED_SCOPE. Test harnesses must set up inside the root and write from outside it.⚠️ A split effect's apply callback must return a cleanup function orundefined.v => setX(v)returns the setter's return value and throws "invalid cleanup value". Use a block body.⚠️ An uncaught error inside an effect halts the entire reactive system (REACTIVITY_HALTED) for the rest of the module. Intentionally-throwing tests need their own file.batchis gone; reads lag writes. Not just effects — a plainsignal()read aftersetSignal()still returns the old value until the next microtask or an explicitflush(). Every write-then-read mustflush()first. Never paper over this withsetTimeoutor tick counts.@solidjs/testing-library'srenderpopulates the container synchronously; subsequent updates needflush().- Return getter objects (not objects of accessors, not memoized fresh objects) from composables whose result is read once by a Solid context or passed as a prop.
- There is no
onMount. Solid 2 does not export one. The post-commit-mount equivalent iscreateEffect(() => undefined, () => { … }): a constant compute phase runs the apply phase once, after render, and never under SSR — which is what React's mountuseEffectdoes. useContextneeds an owner, default or not. An explicit context default makes a read outside a provider safe, but Solid 2 still throwsContext can only be accessed under a reactive rootwith no owner on the stack. A hook that must be safe outside a component needs agetOwner()guard in addition to the default (useQueryBuilderConfighas one).
createStore and reconcile are exported from solid-js now, not solid-js/store.
reconcile(value, key?)—keyis the 2nd positional argument and defaults to'id', which is exactly what this port needs. The 1.x{ key, merge }options object is not the 2.0 shape and throws.createProjection(fn, seed, options?)is a derived, read-only store with the same'id'default key. It can be driven from a non-reactive external source (the manager's subscribe callback) by bumping a version signal from that callback and reading the signal infn; this is whatcreateQueryBuilderStateuses.createStore's setter takes a draft callback (setStore(draft => { draft.x = … })). There is no 1.xsetStore('key', value)path-argument form; it throwsfn is not a function.
snapshot() before every manager write — but a test that asserts this needs two details right, or
it passes whether or not the snapshot() is there:
createStoredeclines to proxy an already-frozen object, so a fixture that has been through a manager earlier in the same file is stored raw and there is no proxy to reject. Use a fresh object.- Immer only sees the proxy when the manager does not re-prepare the input. A query whose rules
have no
idis re-prepared into plain objects and never throws; the same query withids throws'ownKeys' on proxy: trap result did not include 'v'. Always give store fixtures explicitids.
- Build class strings with core's
clsxexclusively. Never template interpolation. - Element order and conditional rendering are specified by React's
Rule.tsx/RuleGroup.tsx. Read them as a spec, not as code to translate. Labelis a plain function component, not a fragment-returning helper with stray whitespace.defaultControlElementsis an object of getters, deliberately.Rule→RuleSubQuery→defaultControlElements→Ruleis a real import cycle (a subquery builds its own state, which needs the default controls). Eager entries throw a TDZReferenceErrorwheneverRule.tsxis the module the cycle is entered through. Do not "simplify" them back to plain properties.- A subquery renders bare
<div>s for its group header/body, not arule-groupelement (React'sRuleWithSubQueryGroupComponentsWrapper), and it is not customizable.
vitest.conformance.config.ts runs two projects, because the two fixture layers demand
opposite render modes. This is structural, not cosmetic — one plugin instance cannot serve both.
| Project | Compilation | Environment | Renders |
|---|---|---|---|
conformance-ssr |
generate: 'ssr', hydratable: false |
node |
renderToString, controlled query — the static layer |
conformance-dom |
default dom |
jsdom |
testing-library + flush(), uncontrolled defaultQuery — the post-flush layer |
- The static layer is rendered server-side deliberately. The fixtures come from
renderToStaticMarkupwith no effects run; Solid's clientrender()runs effects, so the "extract before the scheduler flushes" trick both prior ports used is unavailable. SSR runs no effects at all, so this is an exact match rather than an approximation — and it exercises the ssr path in all 50 cases for free. ⚠️ solid({ ssr: true, … })on the ssr project is load-bearing and is not redundant withsolid: { generate: 'ssr' }.vite-plugin-solid@3injects abrowsercondition in test mode (isTestMode && !options.ssr),@solidjs/weblistsbrowserbeforenode, and the browser build'srenderToStringis a stub returningundefined.ssr: truesuppresses the injection (and the plugin's forcedenvironment: 'jsdom');options.solidmerges last, so the explicitgenerate/hydratablestill win. Do not "fix" this by hand-writingresolve.conditions.hydratable: false— hydration keys land as attributes and would break byte-identicalclasscomparison. (Marker comments are ignored by extraction; attributes are not.)cases.tscarries no rendering. The render helpers are split intorender-ssr.tsxandrender-dom.tsxso the ssr project never imports@solidjs/testing-libraryand vice versa.scenarios.tsx, not.ts—getValueEditorSeparatorreturns JSX, which each project must compile for its own target.⚠️ actions.solid.test.tsmustflush()once aftercreateRootbefore replaying. Effects created inside a root are queued, not run eagerly, so the controlled-querysync effect's first run otherwise lands on the flush after the first op and silently reverts it. Found the hard way: 4 of 19 cases failed with the ops apparently never applied.extract.tsexposes bothextractFromContainerandextractFromMarkup(upstream'sschemaVersion2 split). The markup form builds its ownJSDOMwhen there is no globalDOMParser, which is what lets the ssr project run in thenodeenvironment and thereby prove a server render needs no document.
jsxImportSourceis"@solidjs/web".solid-js@2owns no JSX namespace and nojsx-runtime.JSXandComponentPropsimport from@solidjs/web;Componentstays onsolid-js.ReactNode→LabelNode(JSX.Element | string); titles staystring.ComponentType<P>→ Solid'sComponent<P>.- Use
import typefor type-only imports (verbatimModuleSyntaxis on). ReactMouseEvent→ the DOMMouseEvent.QueryBuilderPropsstays the conditional type React writes. Solid components are plain functions with no compile-time prop enumeration, so there is noQueryBuilderPropsBase, noRuleTypeOf<RG>helper, and no re-widening cast inside components (all of which Vue needed).src/types/types.test-d.tsis compiled bytsc(bun run check), not run by Vitest. It is a two-sided gate: a failed assertion errors, and an@ts-expect-errorthat stops erroring (member quietly re-added) errors asTS2578. Both directions are proven.- TypeScript is pinned to
^5.9. Neithervite-plugin-solid's babel preset nor the declaration pipeline is validated against TypeScript 7.
Standing rule: every gate must be proven to fail. When a gate is added, deliberately break it, record that it went red, then revert. A gate that cannot fail is worse than none.
Current gates: check:versions, fmt:check, build, check, check:exports,
lint, test:coverage (global 80% lines, plus a per-directory 90% lines on packages/*/src/**,
which subsumes the narrower packages/*/src/reactive/** key; both
non-vacuous, both proved red with no injected dead code), conformance (237 assertions: 50
static classnames, 50 accessible descriptions, 50 post-flush classnames, 58 action sequences, 19
port-side action sequences, plus alignment/drift/format), test:ssr (both halves), and
check including the examples.
The a11y gate was proved red and reverted: deleting the title binding from
ValueSelector.tsx turned all nine axe cases red on the WCAG assertion (select-name, a
level-A violation, not merely a best-practice one) while all three keyboard tests stayed green.
Note the best-practice assertion is an equality check against ['label-title-only'], not a
suppression — RQB labels selectors and text editors with title alone and DOM parity is locked, so
that one rule is accepted (recorded under "Known limitations" in CHANGELOG.md) while any other
best-practice regression still fails.
The four conformance gates were each proved red and reverted:
- DOM parity —
conformance-gate-probeappended toActionElement.tsx's class turned exactly 100 cases red (50 static + 50 post-flush), which is the split the two projects promise. schemaVersion—EXPECTED_SCHEMA_VERSION = 3madeconformance:fetchexit 1 with the "updatetest/conformancebefore bumping the tag" message.- Scenario drift — renaming the local
allControlsscenario turned the drift test (and the three case-alignment tests) red while all 50 rendered cases stayed green. - Value-editor reset — an early
returnincreateValueEditorReset's apply phase left conformance at 237/237 green (as upstream predicts: every case isdiffersFromStatic: false) while turning 5 of the 9 post-mount unit assertions red. That asymmetry is exactly why this one cannot be proved through the post-flush fixture alone.
Separately confirmed: with test/fixtures/ removed, bun run test still passes 284/284 and
conformance:test fails with the actionable "run bun run conformance:fetch" message rather than
an opaque parse error.
The example gate was proved red twice, independently, and reverted both times:
document.title injected into QueryBuilder.tsx turned the served response into a 500 and took
19 assertions with it; a one-attribute divergence in examples/ssr/src/entry-client.tsx turned the
hydration surface comparison red while every markup assertion stayed green. Those two failure
modes share no code, which is the point of having both.
All five were proven red once and reverted: coverage (threshold to 99 + an injected
uncovered function), export-condition order (import moved first), export-condition
target (solid repointed at dist/index.js), the SSR markup assertion (component's label
dropped), and check-dist-specifiers (a directory import appended to dist/index.d.ts).
Four of the five were re-proved red on the Solid 2 toolchain — a gate proved red
under Solid 1 is not evidence about Solid 2, since the plugin, the resolver behavior and the SSR
renderer all changed. Coverage, condition order (both layers fired), condition target, and
SSR markup under the synchronous renderToString. check-dist-specifiers is unaffected by
the runtime swap; it was re-run against the rebuilt dist instead.
- Checking
exports['.'].solidby key lookup instead of by position. Order is the bug; presence is not. - Scanning
dist/index.jsfor ssr-only specifiers to prove the dom and ssr builds differ.dist/index.jsis a pure re-export barrel with no runtime code, so the check can never fire. Build distinctness is now covered properly by the two Node resolutions intest:ssr.
src/index.ts a pure export *, v8 reports 0/0 and the
threshold passes vacuously. The proof must also inject an uncovered multi-line function
body to demonstrate the gate is live; non-vacuity is re-confirmed against real reactive-layer code.
Coverage is configured in the root vitest.config.ts only. A coverage block in the
package's vite.config.ts is silently ignored when the suite runs through test.projects, which
is how CI runs it.
packages/solid-querybuilder/test/fixtures/— downloaded byscripts/fetch-fixtures.ts, gitignored. A fresh clone must passbun run testwithout them.
Remote-less. The repo is a local git repo with no GitHub remote and nothing pushed.
.github/workflows/ci.yml exists so it is in place whenever a remote is added.