|
| 1 | +# solid-querybuilder Development Guide |
| 2 | + |
| 3 | +**COMMUNICATION STYLE**: Be aggressively concise. Prioritize brevity over grammar. |
| 4 | + |
| 5 | +## Project overview |
| 6 | + |
| 7 | +The package `solid-querybuilder` is a Solid 1.x/2.x port of |
| 8 | +[React Query Builder](https://react-querybuilder.js.org), built on the published |
| 9 | +`@react-querybuilder/core`. The port's defining constraint is **full DOM parity**: tag name, |
| 10 | +document order, `data-testid`, `data-path`, and byte-identical `class` attributes must match |
| 11 | +React Query Builder's output for all conformance cases. |
| 12 | + |
| 13 | +Blueprints: `svelte-querybuilder@0.1.1` (Phase 1) and `@react-querybuilder/vue@0.2.0` (Phase 2). |
| 14 | +Deviate only where Solid idiom demands. |
| 15 | + |
| 16 | +``` |
| 17 | +solid-querybuilder/ |
| 18 | +├── packages/solid-querybuilder/ # the library |
| 19 | +│ ├── src/ # components, reactive layer, types, styles |
| 20 | +│ ├── test/conformance/ # DOM-parity harness (fixtures gitignored) |
| 21 | +│ └── scripts/ # build/check/ssr-smoke helpers |
| 22 | +└── examples/ # demo (Vite) and a SolidStart SSR gate |
| 23 | +``` |
| 24 | + |
| 25 | +Wiring strategy is **hybrid**: `QueryManager` owns every write (history, guards, `reconfigure`); |
| 26 | +an internal `createStore` mirror (`reconcile`d by `id`) is the read path. See |
| 27 | +`~/git/SOLID_QB_PLAN.md` for the full rationale and step-by-step plan; this file only records the |
| 28 | +standing rules that apply to every step. |
| 29 | + |
| 30 | +## Commands |
| 31 | + |
| 32 | +- `bun install` |
| 33 | +- `bun run build` — vite lib build (dom), then `tsc --jsx preserve` (source), then types, then css |
| 34 | +- `bun run test` / `bun run test:coverage` — Vitest. **Never `bun test`**; that is Bun's builtin |
| 35 | + runner and bypasses Vitest entirely. |
| 36 | +- `bun run conformance` — fetch fixtures, then run the DOM-parity suites |
| 37 | +- `bun run test:ssr` — resolves the `solid` export condition and renders through |
| 38 | + `renderToStringAsync`; a real gate from step 1, superseded (but not replaced) by the SolidStart |
| 39 | + gate at step 8 |
| 40 | +- `bun run check` — `tsc --noEmit` |
| 41 | +- `bun run lint`, `bun run fmt`, `bun run fmt:check` |
| 42 | +- `bun run check:all` — everything CI runs |
| 43 | + |
| 44 | +## Authoring constraints |
| 45 | + |
| 46 | +### The Solid export condition |
| 47 | + |
| 48 | +`exports['.']` is the Solid triple: `solid` (raw JSX, resolved first) → `types` → `import` |
| 49 | +(dom-compiled fallback). **`"solid"` must come first** in the conditions object — Node picks the |
| 50 | +_first_ matching key, so a `solid` entry that merely exists but sits after `import` is silently |
| 51 | +dead. A consumer's `vite-plugin-solid` (or SolidStart) resolves it and compiles the raw JSX for |
| 52 | +its own target (`dom` in the browser, `ssr` on the server). Getting the order wrong ships a |
| 53 | +package that renders fine in the browser and silently breaks SSR/hydration. |
| 54 | + |
| 55 | +`scripts/ssr-smoke.ts` is the gate, and it checks order two ways: a literal |
| 56 | +`Object.keys(exports['.'])[0] === 'solid'` assertion, and Node's real resolver invoked twice |
| 57 | +(`--conditions=solid` must give `dist/source/index.jsx`, no conditions must give `dist/index.js`). |
| 58 | +A key _lookup_ (`exports['.'].solid`) is order-blind and does not gate anything — do not |
| 59 | +regress it back to that. |
| 60 | + |
| 61 | +Keep the script even after step 8 supersedes it with the SolidStart gate; it is the only thing |
| 62 | +that checks the export condition in isolation. |
| 63 | + |
| 64 | +### The SSR smoke test runs one Solid instance |
| 65 | + |
| 66 | +`scripts/ssr-smoke-entry.jsx` imports **both** `solid-js/web` and the library, and is loaded |
| 67 | +through `vite.ssrLoadModule`. That is load-bearing: `ssr.noExternal` gives Vite's module graph its |
| 68 | +own copy of `solid-js`, so importing `renderToStringAsync` in the host process instead would |
| 69 | +render with a _different instance_ than the component was compiled against. Solid keeps |
| 70 | +owner/`sharedConfig` state at module scope, so the copies do not share it — a trivial component |
| 71 | +survives this, but anything using `createContext`/`createStore`/`createEffect` (i.e. |
| 72 | +`QueryBuilder`, from step 4) does not. |
| 73 | + |
| 74 | +Vite's `ssr.resolve.conditions` must be `['solid', 'node', 'development']`: `solid` so the library |
| 75 | +resolves to its raw-JSX entry, **`node` so `solid-js/web` resolves to its server build**. Listing |
| 76 | +`solid` alone clobbers Vite's defaults and hands back the browser build, whose |
| 77 | +`renderToStringAsync` is a stub that throws. Never add `browser`. |
| 78 | + |
| 79 | +The entry is `.jsx`, not `.tsx`, deliberately: it stays out of the typecheck project so |
| 80 | +`bun run check` does not depend on `dist/` existing. |
| 81 | + |
| 82 | +### Relative import specifiers |
| 83 | + |
| 84 | +Must end `.js`, not `.ts`, in `src/`. `rewriteRelativeImportExtensions` is off, so `tsc` copies |
| 85 | +specifiers into the emitted `.d.ts` verbatim. `check-dist-specifiers.ts` additionally allows a |
| 86 | +`./foo.js` specifier in a `.d.ts` to resolve to a sibling `foo.d.ts` with no `foo.js` beside it (a |
| 87 | +type-only module erased by the bundler), and allows `./foo.jsx` under `dist/source`. |
| 88 | + |
| 89 | +### Reactivity |
| 90 | + |
| 91 | +- **Never destructure props.** `splitProps`/`mergeProps` only. A destructure at the top of a |
| 92 | + component silently severs reactivity and passes every type check. This is the single most likely |
| 93 | + Solid-specific defect class — check it at review of every component. |
| 94 | +- **`unwrap()` before handing anything to the manager.** The manager's Immer deep-freeze rejects a |
| 95 | + store proxy. |
| 96 | +- **`unwrap()` the manager itself** before reading its history (`UndoRedoActions`). |
| 97 | + `QueryManager` keeps history in private class fields, which a `Proxy` cannot read through. |
| 98 | +- Effects that write back into state use `createEffect(on([...explicit deps], ...))`, never a bare |
| 99 | + auto-tracking effect — the tracked set changing across branches is exactly the loop failure mode. |
| 100 | + Writes go through `untrack`, plus a re-entrancy flag. |
| 101 | +- Return getter objects (not objects of accessors, not memoized fresh objects) from composables |
| 102 | + whose result is read once by a Solid context or passed as a prop. |
| 103 | + |
| 104 | +### DOM parity |
| 105 | + |
| 106 | +- Build class strings with core's `clsx` exclusively. Never template interpolation. |
| 107 | +- Element order and conditional rendering are specified by React's `Rule.tsx` / `RuleGroup.tsx`. |
| 108 | + Read them as a spec, not as code to translate. |
| 109 | +- `Label` is a plain function component, not a fragment-returning helper with stray whitespace. |
| 110 | + |
| 111 | +### Types |
| 112 | + |
| 113 | +- `ReactNode` → `LabelNode` (`JSX.Element | string`); titles stay `string`. |
| 114 | +- `ComponentType<P>` → Solid's `Component<P>`. |
| 115 | +- Use `import type` for type-only imports (`verbatimModuleSyntax` is on). |
| 116 | +- **TypeScript is pinned to `^5.9`.** Neither `vite-plugin-solid`'s babel preset nor the |
| 117 | + declaration pipeline is validated against TypeScript 7. |
| 118 | + |
| 119 | +## Gates |
| 120 | + |
| 121 | +**Standing rule: every gate must be proven to fail.** When a step adds a gate, deliberately break |
| 122 | +it, record that it went red, then revert. A gate that cannot fail is worse than none. |
| 123 | + |
| 124 | +Current gates (step 1): `fmt:check`, `build`, `check`, `check:exports`, `lint`, `test:coverage` |
| 125 | +(global 80% lines — vacuous until step 3 adds real executable code in `src/reactive/`), `test:ssr`. |
| 126 | +(`conformance` is a stub that exits 0 until step 6; it is not a gate yet.) |
| 127 | + |
| 128 | +All five were proven red at step 1 and reverted: coverage (threshold to 99 + an injected |
| 129 | +uncovered function), export-condition **order** (`import` moved first), export-condition |
| 130 | +**target** (`solid` repointed at `dist/index.js`), the SSR **markup** assertion (component's label |
| 131 | +dropped), and `check-dist-specifiers` (a directory import appended to `dist/index.d.ts`). |
| 132 | + |
| 133 | +⚠️ Two assertion shapes that look like gates but are not, both found and removed in review — do |
| 134 | +not reintroduce them: |
| 135 | + |
| 136 | +- Checking `exports['.'].solid` by key lookup instead of by position. Order is the bug; presence |
| 137 | + is not. |
| 138 | +- Scanning `dist/index.js` for ssr-only specifiers to prove the dom and ssr builds differ. |
| 139 | + `dist/index.js` is a pure re-export barrel with no runtime code, so the check can never fire. |
| 140 | + Build distinctness is now covered properly by the two Node resolutions in `test:ssr`. |
| 141 | + |
| 142 | +⚠️ Coverage-gate proof caveat: with `src/index.ts` a pure `export *`, v8 reports `0/0` and the |
| 143 | +threshold passes vacuously. The step-1 proof must also inject an uncovered multi-line function |
| 144 | +body to demonstrate the gate is live; non-vacuity is re-confirmed for real at step 3. |
| 145 | + |
| 146 | +## Coverage |
| 147 | + |
| 148 | +Coverage is configured in the **root** `vitest.config.ts` only. A `coverage` block in the |
| 149 | +package's `vite.config.ts` is silently ignored when the suite runs through `test.projects`, which |
| 150 | +is how CI runs it. |
| 151 | + |
| 152 | +## Generated / fetched files |
| 153 | + |
| 154 | +- `packages/solid-querybuilder/test/fixtures/` — downloaded by `scripts/fetch-fixtures.ts` (added |
| 155 | + at step 6), gitignored. A fresh clone must pass `bun run test` without them. |
| 156 | + |
| 157 | +## Repo status |
| 158 | + |
| 159 | +**Local only.** No `git init`, no GitHub remote. `.gitignore` and `.github/workflows/ci.yml` exist |
| 160 | +so they are in place whenever the repo is initialized. |
0 commit comments