@nativele/ts-dom 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ ---
2
+ name: nativele-testing
3
+ description: "Test Nativele components in an app: component tests with Vitest and happy-dom (or jsdom), real-browser tests with Vitest browser mode in headless Chrome, and page tests with Playwright. Covers generating the templates before tests, creating elements, props, events, lists, data-if, stores, slots, test isolation, and what a DOM shim cannot show. Use when a project depends on @nativele/ts-dom and on vitest, @playwright/test or playwright."
4
+ metadata:
5
+ tier: official
6
+ nativele: ">=0.5.2 <0.6.0"
7
+ verified: "2026-11-05"
8
+ tools: "vitest@5.1.0 happy-dom@21.0.4 jsdom@30.1.3 @vitest/browser-playwright@5.1.0 playwright@1.64.1 @playwright/test@1.64.1 vite@8.3.6 typescript@7.0.2"
9
+ author: "Nativele maintainers"
10
+ detects: "vitest @playwright/test playwright"
11
+ ---
12
+
13
+ # Testing Nativele components
14
+
15
+ ## When to use
16
+
17
+ - An app that already uses Nativele (`@nativele/ts-dom`) and needs tests for its components: rendering, props, events, lists, `data-if`, stores and slots.
18
+ - Signals: `@nativele/ts-dom` together with `vitest`, `@playwright/test` or `playwright` in `package.json`, or a request to add tests to a project with `.html` templates and `define()` calls.
19
+ - The starting point is a Vite app with Nativele set up as in the `nativele-vite` Skill (`npm create vite@latest -- --template vanilla-ts`, the `tsDom()` plugin in `vite.config.ts`, components in `src/nv/`). This Skill adds only what testing Nativele needs; it does not teach Vitest or Playwright.
20
+ - Not for writing the components (`npx ts-dom skills add nativele-core`), and not for testing the library itself.
21
+
22
+ The recommended path is Vitest: component tests in happy-dom, and the few behaviors a DOM shim gets wrong in Vitest browser mode on headless Chrome. The alternative is Playwright Test for tests of whole built pages.
23
+
24
+ ## Versions
25
+
26
+ | Tool | Verified version |
27
+ |---|---|
28
+ | @nativele/ts-dom | 0.5.2 |
29
+ | vitest | 5.1.0 |
30
+ | happy-dom | 21.0.4 |
31
+ | jsdom | 30.1.3 (the same component tests pass; not the recommended environment) |
32
+ | @vitest/browser-playwright | 5.1.0 |
33
+ | playwright | 1.64.1 (used only to drive the installed Chrome; no browser is downloaded) |
34
+ | @playwright/test | 1.64.1 |
35
+ | vite | 8.3.6 |
36
+ | typescript | 7.0.2 |
37
+
38
+ Verified on 2026-11-05 with Node 24 and Google Chrome 148, starting from `create-vite` 9.3.4 with the `vanilla-ts` template, with the example project in the Nativele repository (`skills/nativele-testing/example`), which CI runs again when the example or the library changes and once a month. Vitest 5 changed its configuration (`test.api`, `test.projects`, providers as separate packages); with Vitest 4 or older, or another major version of any tool above, treat every step as unverified.
39
+
40
+ ## Set-up
41
+
42
+ 1. Install the test tools:
43
+
44
+ ```sh
45
+ npm i -D vitest@5.1.0 happy-dom@21.0.4
46
+ ```
47
+
48
+ 2. Put the test configuration in the same `vite.config.ts` that has `tsDom()`, so Vitest loads the plugin. The plugin writes the generated `x.html.ts` files and `ts-dom-tags.ts` when Vitest starts, so `vitest run` works on a clean checkout with nothing generated yet. A separate `vitest.config.ts` replaces `vite.config.ts` for Vitest; if the project has one, add `tsDom({ dirs: ["src/nv"] })` to its `plugins` too (see Troubleshooting).
49
+
50
+ ```ts
51
+ // vite.config.ts
52
+ import { defineConfig } from "vitest/config";
53
+ import { tsDom } from "@nativele/ts-dom/vite";
54
+
55
+ export default defineConfig({
56
+ plugins: [tsDom({ dirs: ["src/nv"] })],
57
+ test: {
58
+ projects: [
59
+ { extends: true, test: { name: "unit", include: ["test/**/*.test.ts"], exclude: ["test/**/*.browser.test.ts"], environment: "happy-dom" } },
60
+ ],
61
+ },
62
+ });
63
+ ```
64
+
65
+ 3. Keep the tests in `test/` and add it to `include` in `tsconfig.json` (`"include": ["src", "test"]`). Vitest does not type-check; `ts-dom check src/nv` runs the project's `tsc`, which then checks the tests too, and `ts-dom-tags.ts` types `document.createElement("counter-card")` as the component class, with its props.
66
+ 4. Add the scripts:
67
+
68
+ ```sh
69
+ npm pkg set scripts.test="vitest run --project unit"
70
+ ```
71
+
72
+ Keep `ts-dom check src/nv` in the build script (as `nativele-vite` sets it up) or run it before or after the tests: it is what reports type errors in the tests and the templates.
73
+
74
+ 5. For the real-browser tests, install the Vitest provider and Playwright, and add a second project. It drives the Chrome already installed on the machine (`CHROME_BIN`, or `/usr/bin/google-chrome`, which GitHub's `ubuntu-latest` runners have), so nothing is downloaded and `npx playwright install` is not needed:
75
+
76
+ ```sh
77
+ npm i -D @vitest/browser-playwright@5.1.0 playwright@1.64.1
78
+ npm pkg set scripts.test:browser="vitest run --project browser"
79
+ ```
80
+
81
+ ```ts
82
+ // vite.config.ts: the browser project, next to "unit" in test.projects
83
+ import { playwright } from "@vitest/browser-playwright";
84
+
85
+ const chrome = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
86
+
87
+ {
88
+ extends: true,
89
+ test: {
90
+ name: "browser",
91
+ include: ["test/**/*.browser.test.ts"],
92
+ api: { port: 63432, strictPort: true },
93
+ browser: {
94
+ enabled: true,
95
+ headless: true,
96
+ provider: playwright({ launchOptions: { executablePath: chrome, args: ["--no-sandbox", "--no-zygote", "--disable-gpu", "--disable-dev-shm-usage"] } }),
97
+ instances: [{ browser: "chromium" }],
98
+ },
99
+ },
100
+ },
101
+ ```
102
+
103
+ `headless: true` keeps the run the same locally and in CI. The launch arguments suit containers, WSL and CI runners. Name only the tests that need a real browser `*.browser.test.ts` (limitation 1); everything else stays in happy-dom, which starts in a fraction of the time.
104
+
105
+ 6. Alternative, for tests of whole pages (the built app, every component on it, navigation): Playwright Test against `vite preview`, with the same Chrome:
106
+
107
+ ```sh
108
+ npm i -D @playwright/test@1.64.1
109
+ npm pkg set scripts.test:e2e="playwright test"
110
+ ```
111
+
112
+ ```ts
113
+ // playwright.config.ts
114
+ import { defineConfig } from "@playwright/test";
115
+
116
+ const chrome = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
117
+
118
+ export default defineConfig({
119
+ testDir: "e2e",
120
+ use: {
121
+ baseURL: "http://localhost:4432",
122
+ launchOptions: { executablePath: chrome, args: ["--no-sandbox", "--no-zygote", "--disable-gpu", "--disable-dev-shm-usage"] },
123
+ },
124
+ webServer: { command: "npm run build && npx vite preview --port 4432 --strictPort", url: "http://localhost:4432" },
125
+ });
126
+ ```
127
+
128
+ Add `e2e` to `include` in `tsconfig.json` so `ts-dom check` type-checks the specs, and `test-results` and `playwright-report` to `.gitignore`.
129
+
130
+ ## Example
131
+
132
+ The component under test (from the example project, `src/nv/counter-card.*`):
133
+
134
+ ```html
135
+ <!-- src/nv/counter-card.html -->
136
+ <style scoped>
137
+ b { color: rgb(200, 0, 0); }
138
+ </style>
139
+ <p>{{ label }}: <b>{{ count }}</b></p>
140
+ <button type="button" data-on-click="increment">+1</button>
141
+ <p data-if="many">That is a lot.</p>
142
+ ```
143
+
144
+ ```ts
145
+ // src/nv/counter-card.ts
146
+ import { Component, define, event } from "@nativele/ts-dom";
147
+ import tpl from "./counter-card.html.js";
148
+
149
+ export class CounterCard extends Component(tpl, {
150
+ props: { label: "Count" },
151
+ state: { count: 0 },
152
+ derive: { many: (s) => s.count >= 3 },
153
+ emits: { "count-change": event<{ count: number }>() },
154
+ }) {
155
+ increment(): void {
156
+ this.set({ count: this.state.count + 1 });
157
+ this.emit("count-change", { count: this.state.count });
158
+ }
159
+ }
160
+ define("counter-card", CounterCard);
161
+ ```
162
+
163
+ Its tests, in happy-dom:
164
+
165
+ ```ts
166
+ // test/counter-card.test.ts
167
+ import { afterEach, expect, test } from "vitest";
168
+ import type { EmitEvent } from "@nativele/ts-dom";
169
+ import "../src/nv/counter-card.ts";
170
+ import type { CounterCard } from "../src/nv/counter-card.ts";
171
+
172
+ afterEach(() => document.body.replaceChildren());
173
+
174
+ function mount(label?: string): CounterCard {
175
+ const el = document.createElement("counter-card");
176
+ if (label !== undefined) el.label = label;
177
+ document.body.append(el);
178
+ return el;
179
+ }
180
+
181
+ test("renders when it is appended, with props set as properties before", () => {
182
+ const el = document.createElement("counter-card");
183
+ el.label = "Clicks";
184
+ expect(el.childNodes.length).toBe(0);
185
+ document.body.append(el);
186
+ expect(el.querySelector("p")?.textContent).toBe("Clicks: 0");
187
+ });
188
+
189
+ test("a prop changed later, as a property or an attribute, renders at once", () => {
190
+ const el = mount();
191
+ el.label = "Taps";
192
+ expect(el.querySelector("p")?.textContent).toBe("Taps: 0");
193
+ el.setAttribute("label", "Hits");
194
+ expect(el.querySelector("p")?.textContent).toBe("Hits: 0");
195
+ });
196
+
197
+ test("emit: listen on the element, read detail; the event does not bubble", () => {
198
+ const el = mount();
199
+ const details: { count: number }[] = [];
200
+ let heardByDocument = 0;
201
+ el.addEventListener("count-change", (e) => details.push((e as EmitEvent<CounterCard, "count-change">).detail));
202
+ document.addEventListener("count-change", () => heardByDocument++);
203
+ el.querySelector("button")!.click();
204
+ el.querySelector("button")!.click();
205
+ expect(details).toEqual([{ count: 1 }, { count: 2 }]);
206
+ expect(heardByDocument).toBe(0);
207
+ });
208
+
209
+ test("data-if: the block is absent until its value is truthy", () => {
210
+ const el = mount();
211
+ const lots = () => [...el.querySelectorAll("p")].some((p) => p.textContent === "That is a lot.");
212
+ for (let i = 0; i < 2; i++) el.querySelector("button")!.click();
213
+ expect(lots()).toBe(false);
214
+ el.querySelector("button")!.click();
215
+ expect(lots()).toBe(true);
216
+ });
217
+ ```
218
+
219
+ The steps every test follows:
220
+
221
+ 1. Import the component module for its side effect (`import "../src/nv/counter-card.ts"`): that runs `define()`. A type-only import registers nothing (limitation 2).
222
+ 2. Create the element with `document.createElement(tag)`; set object, array and other props as properties (`el.todos = [...]`), before or after appending. String, number and boolean props can also be attributes.
223
+ 3. Append it to `document.body`. A Light DOM component has no children until it is connected; a component renders when it is appended.
224
+ 4. Assert at once. Rendering is synchronous: after `append`, a prop assignment, `setAttribute`, `el.click()` or `store.set()`, the DOM is already updated, and `emit` listeners have already run. For a form, set the input's `value`, then call `click()` on its submit button or `form.requestSubmit()`: both run the template's `data-on-submit` handler, in happy-dom too. The only waits are for slot changes made after connection (one microtask, below).
225
+ 5. Empty the document in `afterEach` (`document.body.replaceChildren()`): removing the elements runs `disconnected()` and ends their `bind`, `on` and `onCleanup` registrations.
226
+
227
+ Other parts of the API, as in the example project's tests:
228
+
229
+ ```ts
230
+ // lists (test/todo-list.test.ts): items as components; an item's event reaches the parent with hit.key
231
+ const el = document.createElement("todo-list");
232
+ el.todos = todos;
233
+ document.body.append(el);
234
+ expect([...el.querySelectorAll("todo-row")].map((r) => r.textContent?.trim())).toEqual(["Write tests", "Run them"]);
235
+ expect(el.lists.todos.items).toEqual(todos);
236
+ el.querySelectorAll("todo-row")[1]!.querySelector("input")!.click();
237
+ expect(el.state.done).toEqual([2]);
238
+
239
+ // stores and bind (test/user-badge.test.ts): reset the shared store before each test
240
+ beforeEach(() => session.set({ user: "guest" }));
241
+ session.set({ user: "Ada" });
242
+ expect(el.textContent?.trim()).toBe("Signed in as Ada");
243
+
244
+ // Light DOM slots (test/panels.test.ts): children present at connection are placed at once;
245
+ // a child added later is placed by a MutationObserver, after one microtask
246
+ el.append(title);
247
+ await Promise.resolve();
248
+ expect(el.assignedNodes("title")).toEqual([title]);
249
+
250
+ // Shadow DOM slots: the template is in el.shadowRoot; the native slotchange event also fires after one microtask
251
+ el.shadowRoot!.querySelector("slot[name=title]")!.addEventListener("slotchange", onChange);
252
+ ```
253
+
254
+ A real-browser test, for what happy-dom cannot show (limitation 1):
255
+
256
+ ```ts
257
+ // test/move.browser.test.ts
258
+ import { afterEach, expect, test, vi } from "vitest";
259
+ import "../src/nv/todo-list.ts";
260
+ import { TodoRow, type Todo } from "../src/nv/todo-row.ts";
261
+
262
+ afterEach(() => {
263
+ document.body.replaceChildren();
264
+ vi.restoreAllMocks();
265
+ });
266
+
267
+ const todos: Todo[] = [
268
+ { id: 1, title: "Write tests", done: false },
269
+ { id: 2, title: "Run them", done: true },
270
+ ];
271
+
272
+ test("Chrome moves a list item with moveBefore: it stays connected and keeps focus", () => {
273
+ expect("moveBefore" in Element.prototype).toBe(true);
274
+ const el = document.createElement("todo-list");
275
+ el.todos = todos;
276
+ document.body.append(el);
277
+ const disconnected = vi.spyOn(TodoRow.prototype, "disconnected");
278
+ const connected = vi.spyOn(TodoRow.prototype, "connected");
279
+ const input = el.querySelector("todo-row input") as HTMLInputElement;
280
+ input.focus();
281
+ el.todos = [todos[1]!, todos[0]!];
282
+ expect(el.querySelectorAll("todo-row input")[1]).toBe(input);
283
+ expect(disconnected).not.toHaveBeenCalled();
284
+ expect(connected).not.toHaveBeenCalled();
285
+ expect(document.activeElement).toBe(input);
286
+ });
287
+ ```
288
+
289
+ Under happy-dom this test fails: there is no `moveBefore`, both callbacks run and the input loses focus. The example's `test/todo-list.test.ts` asserts the happy-dom behavior, so a later happy-dom release that adds `moveBefore` shows up as a failing test. The Vitest API in browser mode is the same: the example's other happy-dom tests also pass unchanged when run in the browser project, so moving a test there is a matter of renaming it.
290
+
291
+ A page test with Playwright (the alternative):
292
+
293
+ ```ts
294
+ // e2e/app.spec.ts
295
+ import { expect, test } from "@playwright/test";
296
+
297
+ test("the built page renders the components and they respond", async ({ page }) => {
298
+ const errors: string[] = [];
299
+ page.on("pageerror", (e) => errors.push(e.message));
300
+ await page.goto("/");
301
+ const counter = page.locator("counter-card");
302
+ await expect(counter.locator("p").first()).toHaveText("Clicks: 0");
303
+ await counter.getByRole("button", { name: "+1" }).click();
304
+ await expect(counter.locator("b")).toHaveText("1");
305
+ await expect(page.locator("todo-list todo-row")).toHaveText(["Write tests", "Run them in Chrome"]);
306
+ await expect(page.locator("light-panel h2")).toHaveText("Light");
307
+ await expect(page.locator("shadow-panel h2")).toHaveText("Untitled");
308
+ await expect(page.getByRole("heading", { name: "Shadow" })).toBeVisible();
309
+ expect(errors).toEqual([]);
310
+ });
311
+ ```
312
+
313
+ Playwright locators reach into open shadow roots, so `page.locator("shadow-panel h2")` finds the heading inside a `shadow: true` component; its text is the slot fallback, not what is shown (limitation 6).
314
+
315
+ ## Known limitations
316
+
317
+ 1. **What happy-dom does not have.** happy-dom 21 (and jsdom 30) have no `Element.prototype.moveBefore`, so a list item that moves on reorder is taken out and put back: `disconnected()` and `connected()` run, `bind` subscriptions end and start again, and a focused input in it loses focus. Chrome 148 has `moveBefore`, and the same move runs neither callback and keeps focus. Test moves and focus in a `*.browser.test.ts`. Everything else the example covers behaves the same in happy-dom, jsdom and Chrome: custom elements and attribute changes, `MutationObserver` (Light DOM slots), Shadow DOM, native slots and `slotchange`, events, lists, `data-if` and stores. Computed styles are not reliable in a DOM shim (jsdom ignores the `@scope` rule that `<style scoped>` produces; happy-dom applies it); assert styles, layout and real input in the browser.
318
+ 2. **Import the module, not only the type.** `import type { CounterCard }` (or `import { type CounterCard }`) is erased, so `define()` never runs: `document.createElement("counter-card")` returns a plain element that never renders, and `tsc` still types it as `CounterCard` through `ts-dom-tags.ts`. The symptom is `expected undefined to be ...` or `Cannot read properties of null (reading 'click')`. Add `import "../src/nv/counter-card.ts"`. Child components used in a template are imported by its generated module, so importing the parent is enough.
319
+ 3. **One registry per test file.** Vitest runs each test file in a new environment by default (`isolate: true`), in happy-dom and in browser mode: each file starts with an empty document and an empty `customElements`. Inside a file, the document and the registry are shared by all its tests, so empty the document in `afterEach`. With `isolate: false` and the files run one after another in the same worker (`--no-isolate --no-file-parallelism`), they share both: the document keeps the previous file's elements, and a direct `customElements.define()` of a name already defined throws.
320
+ 4. **A tag cannot be redefined.** `define()` with a name that is already defined does nothing and keeps the first class; `customElements.define()` throws an error whose message says the name is already used or registered (in happy-dom, jsdom and Chrome). `vi.resetModules()` followed by a new `import()` creates a new class and new module-level stores, but the tag keeps running the first class, bound to the first store. Reset shared stores with `store.set(...)` in `beforeEach` instead, and give a component defined only for one test file a name no other module uses.
321
+ 5. **Do not add a `data-ref` only for a test.** `ts-dom check` reports a ref that the component class never reads as `unused-ref`, and access from a test does not count. Query the rendered DOM in the test (`el.querySelector("button")`, `el.shadowRoot.querySelector(...)`, `el.assignedNodes(name)`). The same applies to the oxlint rule: turn `ts-dom/unused-markup` off for test folders, as the setup guide says.
322
+ 6. **Text of a slotted heading in a Shadow DOM component.** `textContent` (and Playwright's `toHaveText` on the element inside the shadow root) reads the slot's fallback content, not the slotted nodes: in the example, `shadow-panel h2` has the text `Untitled` while it shows `Shadow`. Read the slot with `el.assignedNodes(name)`, or in Playwright use `getByRole("heading", { name: "Shadow" })`, which follows what is shown. A Light DOM component has the slotted nodes in place, so its text is what is shown.
323
+ 7. **Slot changes after connection are asynchronous.** Children added to a Light DOM component after it is connected are placed by a `MutationObserver`, and `slotchange(name)` runs then; for a Shadow DOM component, the native `slotchange` event fires at the same point. `await Promise.resolve()` once before asserting is enough in happy-dom, jsdom and Chrome. Children present when the element is appended are placed synchronously.
324
+ 8. **Typing custom events.** `addEventListener` types an unknown event name as `Event`, so `e.detail` is a `tsc` error (`TS2339`); cast with `EmitEvent<CounterCard, "count-change">`.
325
+ 9. Not verified: Vitest watch mode after a template change, Vitest 4 or older, browsers other than Chrome, Playwright's downloaded browsers (`npx playwright install`) instead of the installed Chrome, `@vitest/browser-webdriverio`, and testing components inside a UI framework (see that framework's Skill).
326
+
327
+ ## Verify
328
+
329
+ Run in the project:
330
+
331
+ ```sh
332
+ npx vitest run --project unit # expect: every test file passes, on a clean checkout too
333
+ npx ts-dom check src/nv # expect: ts-dom check: no issues (this type-checks the tests)
334
+ npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
335
+ npx vitest run --project browser # expect: the *.browser.test.ts files pass in headless Chrome
336
+ npx playwright test # if you use the alternative: builds, serves on port 4432, passes
337
+ ```
338
+
339
+ The example project's `verify` script runs exactly these, plus the same unit tests under jsdom as a third Vitest project (`"environment": "jsdom"`), which shows that jsdom works for the same tests. Its tests prove each limitation above: the module import, per-file isolation, `define()` twice, `vi.resetModules()`, slot timing in both modes, and the move that happy-dom and Chrome handle differently.
340
+
341
+ ## Troubleshooting
342
+
343
+ | Symptom | Cause | Fix |
344
+ |---|---|---|
345
+ | `Error: Failed to resolve import "./counter-card.html.js" from "src/nv/counter-card.ts". Does the file exist?` | Vitest runs without the `tsDom()` plugin (a `vitest.config.ts` without it replaces `vite.config.ts`), and nothing generated the files | add `tsDom({ dirs: ["src/nv"] })` to the config Vitest loads, or run `ts-dom gen src/nv` before the tests |
346
+ | `expected undefined to be '...'`, `Cannot read properties of null (reading 'click')` on a freshly appended element | the component module was imported only as a type, so the tag is not defined | `import "../src/nv/x.ts"` (limitation 2) |
347
+ | `TS2339: Property 'detail' does not exist on type 'Event'` from `ts-dom check` | the listener's event is a plain `Event` | cast with `EmitEvent<Component, "event-name">` |
348
+ | `TS2339: Property 'label' does not exist on type 'HTMLElement'` on `document.createElement("counter-card")` | `tsc` ran without `ts-dom-tags.ts` (nothing generated it), or the component class is not exported | run `ts-dom check src/nv` instead of plain `tsc`; export the class |
349
+ | `src/nv/x.html:1:1 error [unused-ref] data-ref="..." is never used by the component class` | a ref added for a test | query the DOM in the test and remove the ref (limitation 5) |
350
+ | A test passes alone and fails with others in the same file, or sees elements from another test | the document is shared within a file | `afterEach(() => document.body.replaceChildren())`; reset module-level stores in `beforeEach` |
351
+ | `Error: browserType.launch: Failed to launch chromium because executable doesn't exist at /usr/bin/google-chrome` | no Chrome at that path | set `CHROME_BIN` to the Chrome or Chromium binary |
352
+ | `Error: browserType.launch: Executable doesn't exist at ~/.cache/ms-playwright/...` and `Looks like Playwright was just installed or updated` | `executablePath` is missing from `launchOptions`, so Playwright looks for its own downloaded browser | set `executablePath` as in step 5 |
353
+ | DEPRECATED: `test.browser.api` was deprecated in Vitest 5. Use `test.api` instead. | the port is set where Vitest 4 had it | move `api` from `test.browser` to `test` of the browser project |
354
+ | A test that moves list items passes in happy-dom and fails in Chrome (or the reverse) | happy-dom has no `moveBefore` | assert moves in a `*.browser.test.ts` (limitation 1) |
355
+
356
+ ## See also
357
+
358
+ - Rendering is synchronous, `set` and `batch`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-and-view-state-optional
359
+ - Props as properties and attributes, `emit`, events that do not bubble: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-flow-props-down-events-up
360
+ - Light DOM and Shadow DOM slots, `assignedNodes`, `slotchange`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#light-default-and-shadow
361
+ - Stores and `bind`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#state-store-when-not-using-state
362
+ - Lists, and what moving an item does with and without `moveBefore`: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#moving-items-connecting-and-subscriptions
363
+ - The Vite plugin, `ts-dom check`, and lint in test folders: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md
364
+ - Setting up the Vite app first: `npx ts-dom skills nativele-vite`
@@ -0,0 +1,197 @@
1
+ ---
2
+ name: nativele-vite
3
+ description: "Add Nativele to an existing Vite app without a UI framework, and set up the tsDom() Vite plugin correctly: dirs and Vite's root, multi-page apps, the build script, and what vite dev does when a template or component changes. Use when a project depends on vite (or vite-plus) and @nativele/ts-dom."
4
+ metadata:
5
+ tier: official
6
+ nativele: ">=0.5.1 <0.6.0"
7
+ verified: "2026-11-05"
8
+ tools: "vite@8.3.6 typescript@7.0.2 create-vite@9.3.4 vite-plus@1.5.0"
9
+ author: "Nativele maintainers"
10
+ detects: "vite vite-plus"
11
+ ---
12
+
13
+ # Nativele + Vite
14
+
15
+ ## When to use
16
+
17
+ - A Vite app with no UI framework (for example one started with `npm create vite@latest -- --template vanilla-ts`) that adds components written with Nativele (`@nativele/ts-dom`).
18
+ - Any Vite project with Nativele whose set-up goes beyond the defaults: `root` in a sub-folder, several HTML pages, Vite+, or questions about what `vite dev` does when a template changes.
19
+ - Signals: `vite` or `vite-plus` and `@nativele/ts-dom` in `package.json`, a `vite.config.ts`.
20
+ - With React, follow `nativele-react`; with another framework, `nativele-integration`. The parts about `dirs`, `root` and the build script apply there too, but the hot-update behavior below was verified without a framework.
21
+ - Not for writing the components themselves (`npx ts-dom skills add nativele-core`), and not for setting Vite up.
22
+
23
+ ## Versions
24
+
25
+ | Tool | Verified version |
26
+ |---|---|
27
+ | @nativele/ts-dom | 0.5.1 |
28
+ | vite | 8.3.6 |
29
+ | typescript | 7.0.2 |
30
+ | create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
31
+ | vite-plus | 1.5.0 (checked by hand with `vp dev`, `vp build` and `vp lint`; the example project does not run it) |
32
+
33
+ Verified on 2026-11-05 in Google Chrome 148 with the example project in the Nativele repository (`skills/nativele-vite/example`), which CI runs again when the example or the library changes and once a month. With a different major version of Vite, treat every step as unverified.
34
+
35
+ ## Set-up
36
+
37
+ Starting from the `vanilla-ts` template, which already has `"type": "module"`, a `tsconfig.json` with `"include": ["src"]` and no `strict`, no `vite.config.ts`, and `"build": "tsc && vite build"`:
38
+
39
+ 1. `npm i @nativele/ts-dom`.
40
+ 2. Keep the components in their own folder, for example `src/nv/`, each as `x.html` plus `x.ts` (write them with `nativele-core`).
41
+ 3. Add `"strict": true` to `compilerOptions` in `tsconfig.json`. The template's other options (`verbatimModuleSyntax`, `erasableSyntaxOnly`, `noUnusedLocals`, `allowImportingTsExtensions`) work with the generated files as they are, and `"include": ["src"]` already covers `src/nv`.
42
+ 4. Create `vite.config.ts` (the template has none, so `ts-dom doctor` cannot report the plugin as missing yet):
43
+
44
+ ```ts
45
+ import { defineConfig } from "vite";
46
+ import { tsDom } from "@nativele/ts-dom/vite";
47
+
48
+ export default defineConfig({ plugins: [tsDom({ dirs: ["src/nv"] })] });
49
+ ```
50
+
51
+ `dirs` is resolved against Vite's `root`, not against the folder you run commands in (see step 8). The other options are in the setup guide.
52
+
53
+ 5. Replace `tsc` in the build script with `ts-dom check`, given the same folder:
54
+
55
+ ```sh
56
+ npm pkg set scripts.build="ts-dom check src/nv && vite build"
57
+ ```
58
+
59
+ The generated files are not committed, so on a clean checkout `tsc` runs before anything has written them and fails with `TS2307: Cannot find module './x.html.js'` and follow-on errors. `ts-dom check` writes them, then runs `tsc` and the lint rules. `vite build` writes them too, but it never type-checks.
60
+
61
+ 6. Add `*.html.ts` and `ts-dom-tags.ts` to `.gitignore`.
62
+ 7. Import each page's components from that page's entry module, and write the tags straight into the page's HTML (`<hello-card name="home"></hello-card>`; attributes reach string props). Import the component modules the way the project imports its own (`./nv/hello-card.ts` in this template), and templates always as `./x.html.js`.
63
+ 8. When `root` is a sub-folder (a multi-page app or a monorepo app kept in `web/`), each path is relative to a different folder:
64
+
65
+ | Setting | Relative to | With `root: "web"` |
66
+ |---|---|---|
67
+ | `tsDom({ dirs })` | Vite's `root` | `["src/nv"]` |
68
+ | `ts-dom check`, `doctor`, `gen`, `lint` arguments | the folder they run in (the one with `package.json` and `tsconfig.json`) | `web/src/nv` |
69
+ | `include` in `tsconfig.json` | the tsconfig's folder | `["web/src"]` |
70
+ | `build.rolldownOptions.input` | write absolute paths (see Troubleshooting) | `resolve(import.meta.dirname, "web/about.html")` |
71
+
72
+ Nativele needs nothing per page: each page's entry imports the components that page uses.
73
+
74
+ 9. With Vite+ instead of Vite, import `defineConfig` from `"vite-plus"`; the plugin, `dirs` and the build script are the same, and the lint settings in the setup guide work with `vp lint`. If `vp build` then stops with `Cannot find package 'vite'`, see Troubleshooting.
75
+
76
+ ## Example
77
+
78
+ A two-page app with `root: "web"`, as in the example project:
79
+
80
+ ```
81
+ package.json tsconfig.json vite.config.ts
82
+ web/index.html web/about.html
83
+ web/src/main.ts web/src/about.ts
84
+ web/src/nv/hello-card.html web/src/nv/hello-card.ts web/src/nv/about-card.html web/src/nv/about-card.ts
85
+ ```
86
+
87
+ ```jsonc
88
+ // package.json (scripts)
89
+ "dev": "vite",
90
+ "build": "ts-dom check web/src/nv && vite build"
91
+ ```
92
+
93
+ ```jsonc
94
+ // tsconfig.json: the template's, with "strict": true and
95
+ "include": ["web/src"]
96
+ ```
97
+
98
+ ```ts
99
+ // vite.config.ts
100
+ import { resolve } from "node:path";
101
+ import { defineConfig } from "vite";
102
+ import { tsDom } from "@nativele/ts-dom/vite";
103
+
104
+ const page = (file: string) => resolve(import.meta.dirname, "web", file);
105
+
106
+ export default defineConfig({
107
+ root: "web",
108
+ plugins: [tsDom({ dirs: ["src/nv"] })],
109
+ build: {
110
+ outDir: "../dist",
111
+ emptyOutDir: true,
112
+ rolldownOptions: { input: { main: page("index.html"), about: page("about.html") } },
113
+ },
114
+ });
115
+ ```
116
+
117
+ ```html
118
+ <!-- web/index.html (body); web/about.html has <about-card></about-card> and /src/about.ts -->
119
+ <hello-card name="home"></hello-card>
120
+ <script type="module" src="/src/main.ts"></script>
121
+ ```
122
+
123
+ ```ts
124
+ // web/src/main.ts
125
+ import "./nv/hello-card.ts";
126
+ ```
127
+
128
+ ```html
129
+ <!-- web/src/nv/hello-card.html -->
130
+ <p>Hello, {{ name }}</p>
131
+ <button type="button" data-on-click="bump">clicked {{ count }}</button>
132
+ ```
133
+
134
+ ```ts
135
+ // web/src/nv/hello-card.ts
136
+ import { Component, define } from "@nativele/ts-dom";
137
+ import tpl from "./hello-card.html.js";
138
+
139
+ export class HelloCard extends Component(tpl, { props: { name: "" }, state: { count: 0 } }) {
140
+ bump(): void {
141
+ this.set({ count: this.state.count + 1 });
142
+ }
143
+ }
144
+ define("hello-card", HelloCard);
145
+ ```
146
+
147
+ ## Known limitations
148
+
149
+ 1. **Every change reloads the page; component state is lost.** A custom element tag can be defined only once, so there is no hot swap of a component. During `vite dev`:
150
+
151
+ | Change | What happens |
152
+ |---|---|
153
+ | A template is edited | its `x.html.ts` is regenerated and the browser reloads; the terminal prints `[vite] (client) page reload src/nv/x.html` |
154
+ | A template is saved with no change | no reload (the terminal still prints the `page reload` line) |
155
+ | A template no page has loaded since the dev server started is edited | no reload |
156
+ | A template some page has loaded is edited | every open page reloads, also pages that do not use it |
157
+ | A template that `ts-dom gen` rejects is saved | no reload: the page keeps the last good version, and the error (`file:line:col error [rule-id] ... Fix: ...`) is printed only in the terminal, with no overlay in the browser |
158
+ | A template uses a name the component does not have (`{{ nope }}`) | it is regenerated and reloads, the hole stays empty, and nothing reports it: only `ts-dom check` does |
159
+ | A component `.ts` is edited | Vite reloads the page, and the new class runs |
160
+
161
+ 2. **Do not call `import.meta.hot.accept()` in a module that imports components.** Vite then re-runs the modules instead of reloading, `define()` finds the tag already defined and keeps the old class, and the edit silently has no effect until a manual reload.
162
+ 3. **Neither `vite dev` nor `vite build` type-checks.** Run `ts-dom check` (the build script in step 5 does), and run it after a template change that `vite dev` accepted without complaint.
163
+ 4. **A wrong `dirs` fails without a warning from the plugin.** If `dirs` does not point at the templates from Vite's `root` (for example `["web/src/nv"]` with `root: "web"`), nothing is generated, and the first sign is an unresolved `./x.html.js` (see Troubleshooting).
164
+ 5. **Pass one folder to every `ts-dom` command.** `ts-dom-tags.ts` is written into the folder a command is given, so `ts-dom check web` next to `ts-dom check web/src/nv` leaves a second `ts-dom-tags.ts` in `web/`; delete it and use the same folder everywhere.
165
+ 6. Not verified: hot updates under a framework plugin (React Fast Refresh, Vue), server-side rendering, Vite library mode, an npm workspaces monorepo where the app has its own `package.json` (only `root` in a sub-folder of one package was verified), and Vite versions other than 8.3.
166
+
167
+ ## Verify
168
+
169
+ Run in the project (with `root: "web"`, the folder is `web/src/nv`):
170
+
171
+ ```sh
172
+ npm run build # expect: ts-dom check: no issues, then ✓ built, and one HTML file per page in the output
173
+ npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right
174
+ ```
175
+
176
+ Then run `npm run dev`, open every page and check that its components render and respond. Edit a template and check that the page reloads once and shows the change. The example project's [`verify.mjs`](https://github.com/Pixmi/nativele/blob/main/skills/nativele-vite/example/verify.mjs) checks with Playwright that `vite build` alone writes the generated files, that both built pages work under `vite preview`, and every row of the table in Known limitations, restoring the files it edits.
177
+
178
+ ## Troubleshooting
179
+
180
+ | Symptom | Cause | Fix |
181
+ |---|---|---|
182
+ | `TS2307: Cannot find module './x.html.js'` from `npm run build` on a clean checkout | the build script starts with `tsc`, before anything generated the files | `"build": "ts-dom check <folder> && vite build"` |
183
+ | `vite build`: `[UNRESOLVED_IMPORT] Could not resolve './x.html.js'`; `vite dev`: `Failed to resolve import "./x.html.js"` and a 500 for the module | `dirs` is not relative to Vite's `root`, or leaves out the folder | give `dirs` relative to `root` (`["src/nv"]` with `root: "web"`) |
184
+ | `ts-dom check`: `[dir-not-found] The folder src does not exist` | the commands default to `src` in the folder they run in | pass the folder from there: `ts-dom check web/src/nv` |
185
+ | `ts-dom doctor`: `[tsconfig-misses-templates]`; `ts-dom check`: `TS18003: No inputs were found` | `include` is relative to the tsconfig, not to Vite's `root` | `"include": ["web/src"]` |
186
+ | An edit to a component has no effect until the page is reloaded by hand | a module calls `import.meta.hot.accept()` | remove it |
187
+ | A template edit never reaches the page, and the terminal shows `error [rule-id]` | the template breaks a template rule | fix it by the `Fix:` hint; once the template generates again, the page shows it |
188
+ | `vite dev`: `(!) Failed to run dependency scan ... failed to resolve rolldownOptions.input value: "web/index.html"` | `vite dev` resolves a relative `input` against `root` | write absolute paths, as in the example |
189
+ | Vite warns `ESM syntax in a file loaded as CommonJS (vite.config.ts:1:1)` | `package.json` has no `"type": "module"` | `npm pkg set type=module` |
190
+ | `vp build`: `Cannot find package 'vite' imported from .../vitest/dist/config.js` | npm left `vite` nested under `vite-plus` on the first install (seen with npm 11.19, also without Nativele) | run `npm i` once more |
191
+
192
+ ## See also
193
+
194
+ - The Vite plugin, its options and the dev server: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#with-vite-optional
195
+ - Lint settings for `vite.config.ts` (Vite+): https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#lint-optional
196
+ - What `ts-dom check`, `doctor` and `gen` check and write: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#checks
197
+ - Writing the components: `npx ts-dom skills nativele-core`