@nativele/ts-dom 0.5.1 → 0.5.3
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.
- package/CHANGELOG.md +51 -0
- package/README.md +8 -1
- package/README.zh-TW.md +8 -1
- package/dist/{a11y-CV3nBgBn.mjs → a11y-Bw-8ZIAK.mjs} +65 -21
- package/dist/cli.mjs +24 -16
- package/dist/index.mjs +3 -3
- package/dist/{lint-DfUAh7t3.mjs → lint-qEPgmmMa.mjs} +2 -2
- package/dist/oxlint-plugin.mjs +2 -2
- package/dist/vite-plugin.mjs +17 -25
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/README.md +8 -4
- package/skills/nativele-architecture/SKILL.md +541 -0
- package/skills/nativele-integration/SKILL.md +92 -22
- package/skills/nativele-state-management/SKILL.md +319 -0
- package/skills/nativele-tailwind/SKILL.md +220 -0
- package/skills/nativele-tanstack-query/SKILL.md +302 -0
- package/skills/nativele-testing/SKILL.md +364 -0
- package/skills/nativele-zod/SKILL.md +345 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nativele-tailwind
|
|
3
|
+
description: "Style Nativele components with Tailwind CSS v4 in a Vite app that already uses Tailwind: classes in .html templates and in render, which files Tailwind scans, dynamic class names, <style scoped> next to utilities, and bringing the Tailwind stylesheet into shadow: true components. Use when a project depends on tailwindcss and @nativele/ts-dom."
|
|
4
|
+
metadata:
|
|
5
|
+
tier: experimental
|
|
6
|
+
nativele: ">=0.5.2 <0.6.0"
|
|
7
|
+
verified: "2026-11-05"
|
|
8
|
+
tools: "tailwindcss@4.3.4 @tailwindcss/vite@4.3.4 vite@8.3.6 typescript@7.0.2 create-vite@9.3.4"
|
|
9
|
+
author: "Nativele maintainers"
|
|
10
|
+
detects: "tailwindcss"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Nativele + Tailwind CSS
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- A Vite app that already uses Tailwind CSS v4 through `@tailwindcss/vite` and adds components written with Nativele (`@nativele/ts-dom`), or a Nativele app that adds Tailwind that way.
|
|
18
|
+
- Signals: `tailwindcss`, `@tailwindcss/vite` and `@nativele/ts-dom` in `package.json`, a CSS file with `@import "tailwindcss";`.
|
|
19
|
+
- Not for setting Tailwind up: follow Tailwind's own guide for Vite first, and check that a utility class works on a plain page before adding Nativele. Not for Tailwind v3, the PostCSS plugin or the Tailwind CLI (not verified).
|
|
20
|
+
- Not for adding Nativele to Vite (`npx ts-dom skills add nativele-vite`) or writing the components (`npx ts-dom skills add nativele-core`).
|
|
21
|
+
|
|
22
|
+
## Versions
|
|
23
|
+
|
|
24
|
+
| Tool | Verified version |
|
|
25
|
+
|---|---|
|
|
26
|
+
| @nativele/ts-dom | 0.5.2 |
|
|
27
|
+
| tailwindcss | 4.3.4 |
|
|
28
|
+
| @tailwindcss/vite | 4.3.4 |
|
|
29
|
+
| vite | 8.3.6 |
|
|
30
|
+
| typescript | 7.0.2 |
|
|
31
|
+
| create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
|
|
32
|
+
|
|
33
|
+
Verified on 2026-11-05 in Google Chrome 148, by reading computed styles with Playwright both against `vite` (dev) and against `vite build` served by `vite preview`, in an app made with `npm create vite@latest -- --template vanilla-ts`, given Tailwind by Tailwind's Vite guide, then Nativele by `nativele-vite`, in a git repository. With a different major version of Tailwind, treat every step as unverified.
|
|
34
|
+
|
|
35
|
+
## Set-up
|
|
36
|
+
|
|
37
|
+
The starting point: `src/style.css` is `@import "tailwindcss";`, `src/main.ts` imports `./style.css`, `vite.config.ts` has `tailwindcss()` in `plugins`, and Nativele is set up by `nativele-vite` (components in `src/nv/`, `*.html.ts` and `ts-dom-tags.ts` in `.gitignore`, `"build": "ts-dom check src/nv && vite build"`).
|
|
38
|
+
|
|
39
|
+
1. Put both plugins in `vite.config.ts`. Their order did not change the result:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { defineConfig } from "vite";
|
|
43
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
44
|
+
import { tsDom } from "@nativele/ts-dom/vite";
|
|
45
|
+
|
|
46
|
+
export default defineConfig({ plugins: [tsDom({ dirs: ["src/nv"] }), tailwindcss()] });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
2. Keep `import "./style.css"` in the entry module. For Light DOM components (the default) nothing else is needed: Tailwind's automatic source detection reads the `.html` templates and the component `.ts` files, so a class written only in a template, only on a tag in `index.html`, or only as a full string in TypeScript (`classList.toggle("tracking-[0.3em]", on)` in `render`) is generated, in `vite dev` and in `vite build`. No `@source` is needed while the components are inside the project folder.
|
|
50
|
+
3. The generated `*.html.ts` files do not need to be scanned. Tailwind skips every file matched by `.gitignore` (also outside a git repository), and with the generated files not ignored the built CSS was byte for byte the same. The Skills that `ts-dom skills add` copies into `.agents/` (or `.claude/`) are scanned, though, and their example classes end up in the build; exclude that folder under the import, relative to the CSS file: `@source not "../.agents";`.
|
|
51
|
+
4. Only when templates live outside the project folder (for example a sibling package of a monorepo), add a `@source` line, with the path relative to the CSS file:
|
|
52
|
+
|
|
53
|
+
```css
|
|
54
|
+
/* src/style.css */
|
|
55
|
+
@import "tailwindcss";
|
|
56
|
+
@source "../../shared-nv";
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Nativele needs that folder too: list it in `tsDom({ dirs })`, in the arguments of `ts-dom check` and `ts-dom doctor`, and in the tsconfig `include`; keep each component's `.ts` next to its template there (a template whose importer is in another folder is reported as `no-importer`); and the folder must resolve `@nativele/ts-dom` itself, or `tsc` reports `TS2307` for its generated `.html.ts`.
|
|
60
|
+
|
|
61
|
+
5. Only for `shadow: true` components: add one module that turns the same CSS into a constructable stylesheet, and adopt it in each Shadow component's constructor (limitation 3 explains why and what it costs):
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// src/tailwind-sheet.ts
|
|
65
|
+
import css from "./style.css?inline";
|
|
66
|
+
|
|
67
|
+
export const tailwindSheet = new CSSStyleSheet();
|
|
68
|
+
tailwindSheet.replaceSync(css);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
constructor() {
|
|
73
|
+
super();
|
|
74
|
+
this.shadowRoot!.adoptedStyleSheets = [tailwindSheet];
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`?inline` is typed by `vite/client`, which the template's `tsconfig.json` already lists in `types`.
|
|
79
|
+
|
|
80
|
+
## Example
|
|
81
|
+
|
|
82
|
+
A Light DOM component with Tailwind classes in the template, a class toggled in `render`, a class picked from full strings, and a scoped rule that utilities may override:
|
|
83
|
+
|
|
84
|
+
```html
|
|
85
|
+
<!-- src/nv/status-card.html -->
|
|
86
|
+
<style scoped>
|
|
87
|
+
:scope { display: block }
|
|
88
|
+
@layer components {
|
|
89
|
+
.note { color: var(--color-slate-600) }
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
92
|
+
<div class="rounded-lg bg-lime-300 p-4">
|
|
93
|
+
<p data-ref="heading" class="font-bold">{{ heading }}</p>
|
|
94
|
+
<p data-ref="level" class="note">{{ level }}</p>
|
|
95
|
+
</div>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// src/nv/status-card.ts
|
|
100
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
101
|
+
import tpl from "./status-card.html.js";
|
|
102
|
+
|
|
103
|
+
const LEVEL = { ok: "text-emerald-900", warn: "text-rose-900" } as const;
|
|
104
|
+
|
|
105
|
+
export class StatusCard extends Component(tpl, {
|
|
106
|
+
props: { heading: "", level: "ok" as keyof typeof LEVEL, wide: false },
|
|
107
|
+
}) {
|
|
108
|
+
override render(view: { heading: string; level: keyof typeof LEVEL; wide: boolean }): void {
|
|
109
|
+
this.refs.heading.classList.toggle("tracking-[0.3em]", view.wide);
|
|
110
|
+
this.refs.level.className = `note ${LEVEL[view.level]}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
define("status-card", StatusCard);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A Shadow DOM component that adopts the Tailwind sheet from step 5:
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
<!-- src/nv/badge-box.html -->
|
|
120
|
+
<div class="rounded-xl bg-violet-300 p-4 shadow-lg"><slot></slot></div>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// src/nv/badge-box.ts
|
|
125
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
126
|
+
import { tailwindSheet } from "../tailwind-sheet.ts";
|
|
127
|
+
import tpl from "./badge-box.html.js";
|
|
128
|
+
|
|
129
|
+
export class BadgeBox extends Component(tpl, { shadow: true }) {
|
|
130
|
+
constructor() {
|
|
131
|
+
super();
|
|
132
|
+
this.shadowRoot!.adoptedStyleSheets = [tailwindSheet];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
define("badge-box", BadgeBox);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// src/main.ts
|
|
140
|
+
import "./style.css";
|
|
141
|
+
import "./nv/status-card.ts";
|
|
142
|
+
import "./nv/badge-box.ts";
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<!-- index.html (body) -->
|
|
147
|
+
<status-card class="mt-2" heading="Build" level="warn"></status-card>
|
|
148
|
+
<badge-box>inside</badge-box>
|
|
149
|
+
<script type="module" src="/src/main.ts"></script>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Known limitations
|
|
153
|
+
|
|
154
|
+
1. **Class names must be complete strings in a scanned file.** A class built from pieces (`` `text-${tone}-800` ``) is set on the element but has no CSS, in dev and in the build. Pick from full strings instead (the `LEVEL` map in the example), or, when the set really is open, list the classes in the CSS with `@source inline("text-{amber,rose}-800");`.
|
|
155
|
+
2. **Template `<style>` is not processed by Tailwind.** Nativele injects it as written, so `@apply` in it does nothing (verified in a Light DOM `<style scoped>`). Use the theme variables instead (`color: var(--color-teal-400)`): Tailwind emits a theme variable only when it finds its name in a scanned file, and it does find it in the `.html` template.
|
|
156
|
+
3. **Shadow DOM needs the sheet adopted, and the document still needs the stylesheet.** Document styles do not reach a shadow root (see the Light and Shadow table in the components guide), so a `shadow: true` component without step 5 shows its Tailwind classes unstyled. Ways that were tried:
|
|
157
|
+
|
|
158
|
+
| Way | Result |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `adoptedStyleSheets` with `./style.css?inline` (step 5) | styled on the first frame, in dev and build; a class added to a Shadow template during `vite dev` applies after the reload. Costs: the whole Tailwind CSS is in the JavaScript a second time (in the verified app the CSS file was 8.0 kB, and the JavaScript grew by 8.4 kB minified, 2.4 kB gzip), and it is parsed once more; one shared `CSSStyleSheet` serves every instance |
|
|
161
|
+
| A `<link rel="stylesheet">` with `href` from `./style.css?url`, prepended to the shadow root in the constructor | works, and in the build `?url` points at the same CSS file the page already links (no second copy); but the component is unstyled until the stylesheet loads (a flash of unstyled content) |
|
|
162
|
+
| A `<style>` in the template | cannot hold Tailwind output: it is not processed (limitation 2) |
|
|
163
|
+
|
|
164
|
+
Use `adoptedStyleSheets`. Keep `import "./style.css"` in the entry anyway: Tailwind declares its internal variables with `@property`, which the browser registers only from the document. With the sheet adopted only in a shadow root and none in the document, plain utilities (`bg-violet-300`) worked but `shadow-lg` and `translate-x-[5px]` computed to `none`. When components do not need Shadow DOM's isolation, prefer Light DOM: it needs none of this.
|
|
165
|
+
4. **Template styles beat utilities, whatever the specificity.** Tailwind puts utilities in `@layer utilities`, and the `<style>` Nativele injects is not in a layer, so a rule in `<style scoped>` overrides a utility on the same element (`.scoped-red` beat `text-sky-600`). To let utilities override a template's defaults, wrap those rules in `@layer components { }` inside the `<style scoped>` (as in the example); for a single element, the important modifier (`text-sky-600!`) also wins.
|
|
166
|
+
5. **A `dark` class on `<html>` does not reach a shadow root.** With the class-based variant (`@custom-variant dark (&:where(.dark, .dark *));`), `dark:` utilities inside a `shadow: true` component match only an ancestor inside the same shadow root. Copy the class onto the component's inner wrapper (a `data-ref="wrapper"` element around everything that uses `dark:`), once at connect and on every change; the `sync()` at connect also covers a component created after dark mode was turned on:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
override connected(): void {
|
|
170
|
+
const root = document.documentElement;
|
|
171
|
+
const sync = (): void => {
|
|
172
|
+
this.refs.wrapper.classList.toggle("dark", root.classList.contains("dark"));
|
|
173
|
+
};
|
|
174
|
+
const observer = new MutationObserver(sync);
|
|
175
|
+
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
|
176
|
+
this.onCleanup(() => observer.disconnect());
|
|
177
|
+
sync();
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
6. Not verified: Tailwind v3, `@tailwindcss/postcss` and the Tailwind CLI; Vite's `root` in a sub-folder; `dark:` from the `prefers-color-scheme` default inside a shadow root; Nativele components shipped as a package in `node_modules`; a template `<style>` inside a `shadow: true` component together with Tailwind; the CSS nesting fallback of `<style scoped>` in browsers without `@scope`; Tailwind's `prefix()` option and the `dark:` variant inside a shadow root.
|
|
181
|
+
|
|
182
|
+
## Verify
|
|
183
|
+
|
|
184
|
+
Run in the project:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
npm run build # expect: ts-dom check: no issues, then ✓ built
|
|
188
|
+
npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right
|
|
189
|
+
grep -cF '<a class used only in one of your templates>' dist/assets/*.css # expect: 1
|
|
190
|
+
grep -cF '<a class used only in an installed Skill file>' dist/assets/*.css # expect: 0 (the @source not line of step 3)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
For an arbitrary value, search for the escaped form (`grep -cF 'p-\[13px\]'`). Then, against both `npm run dev` and `npx vite preview`, read computed styles in the browser, for example:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
getComputedStyle(document.querySelector("status-card").firstElementChild).backgroundColor // not "rgba(0, 0, 0, 0)"
|
|
197
|
+
getComputedStyle(document.querySelector("badge-box").shadowRoot.firstElementChild).boxShadow // not "none"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Troubleshooting
|
|
201
|
+
|
|
202
|
+
| Symptom | Cause | Fix |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| A class set in `render` does nothing; the element has it in the DOM | the class name is built from pieces | use full strings, or `@source inline(...)` (limitation 1) |
|
|
205
|
+
| Utilities inside a `shadow: true` component do nothing | document styles do not reach a shadow root | step 5 |
|
|
206
|
+
| In a Shadow component colors and padding work, but `shadow-*`, `ring-*` or `translate-*` compute to `none` | the stylesheet is adopted in the shadow root but missing from the document, so Tailwind's `@property` variables are not registered | keep `import "./style.css"` in the entry |
|
|
207
|
+
| A Shadow component flashes unstyled on load | the stylesheet comes from a `<link>` | `adoptedStyleSheets` (step 5) |
|
|
208
|
+
| A utility on an element does not override the template's `<style scoped>` rule | unlayered template styles beat `@layer utilities` | wrap the template rule in `@layer components { }`, or use the `!` modifier |
|
|
209
|
+
| `@apply` in a template `<style>` has no effect | template styles are not processed by Tailwind | use theme variables (`var(--color-...)`) or utility classes in the markup |
|
|
210
|
+
| Templates in another folder of a monorepo are not styled | they are outside the project folder Tailwind scans | `@source "<path relative to the CSS file>"` |
|
|
211
|
+
| The built CSS has classes no component uses, from the Skill files | Tailwind scans `.agents/` (or `.claude/`), where `ts-dom skills add` puts the Skills, unless `.gitignore` matches it | add `@source not "../.agents";` (relative to the CSS file) under `@import "tailwindcss";` |
|
|
212
|
+
| A check that a class is absent from the build fails, although no component uses it | Tailwind scans every file in the project that `.gitignore` does not match, including test scripts that name the class | keep such scripts outside the project, or build the class name from pieces in them |
|
|
213
|
+
|
|
214
|
+
## See also
|
|
215
|
+
|
|
216
|
+
- Light DOM and Shadow DOM, and what reaches a shadow root: https://github.com/Pixmi/nativele/blob/main/docs/components.md#light-default-and-shadow
|
|
217
|
+
- `<style>` and `<style scoped>` in templates: https://github.com/Pixmi/nativele/blob/main/docs/components.md#styles
|
|
218
|
+
- Common DOM operations in `render` (classes): https://github.com/Pixmi/nativele/blob/main/docs/components.md#common-dom-operations-in-render
|
|
219
|
+
- The Vite plugin: https://github.com/Pixmi/nativele/blob/main/docs/tooling.md#with-vite-optional
|
|
220
|
+
- Adding Nativele to Vite: `npx ts-dom skills nativele-vite`
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nativele-tanstack-query
|
|
3
|
+
description: "Fetch server data with TanStack Query (@tanstack/query-core) and show it in Nativele components: an adapter from QueryObserver and MutationObserver to bind, loading, error and refetching states with data-if, lists with data-each, query keys from props, unsubscribing on removal, and mutations with invalidation. Use when a project depends on @tanstack/query-core and @nativele/ts-dom."
|
|
4
|
+
metadata:
|
|
5
|
+
tier: experimental
|
|
6
|
+
nativele: ">=0.5.2 <0.6.0"
|
|
7
|
+
verified: "2026-11-05"
|
|
8
|
+
tools: "@tanstack/query-core@5.106.1 vite@8.3.6 typescript@7.0.2 create-vite@9.3.4"
|
|
9
|
+
author: "Nativele maintainers"
|
|
10
|
+
detects: "@tanstack/query-core"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Nativele + TanStack Query
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
- An app that already uses Nativele (`@nativele/ts-dom`) and fetches server data with TanStack Query's framework-agnostic core, `@tanstack/query-core`: caching, deduplication, refetching and mutations, shown in Nativele components.
|
|
18
|
+
- Signals: `@tanstack/query-core` and `@nativele/ts-dom` in `package.json`, components that call `fetch` in `connected()` and keep their own loading flags.
|
|
19
|
+
- Not for the framework packages (`@tanstack/react-query`, `@tanstack/vue-query`, ...): their hooks belong to their framework; this Skill was verified only with `query-core`.
|
|
20
|
+
- Not a guide to TanStack Query itself (query keys, `staleTime`, cache design): use its own documentation. Not for writing the components themselves: use `nativele-core` (`npx ts-dom skills add nativele-core`).
|
|
21
|
+
|
|
22
|
+
`bind` accepts any object with `get()` and `subscribe(fn)` that returns an unsubscribe function. `QueryObserver` and `MutationObserver` have `getCurrentResult()` and `subscribe(fn)`, so a three-line adapter connects them; Nativele needs nothing else.
|
|
23
|
+
|
|
24
|
+
## Versions
|
|
25
|
+
|
|
26
|
+
| Tool | Verified version |
|
|
27
|
+
|---|---|
|
|
28
|
+
| @nativele/ts-dom | 0.5.2 |
|
|
29
|
+
| @tanstack/query-core | 5.106.1 |
|
|
30
|
+
| vite | 8.3.6 |
|
|
31
|
+
| typescript | 7.0.2 |
|
|
32
|
+
| create-vite (`vanilla-ts` template, the starting point) | 9.3.4 |
|
|
33
|
+
|
|
34
|
+
Verified on 2026-11-05 in Google Chrome 148 against the Vite dev server, in an app made with `npm create vite@latest -- --template vanilla-ts` and set up with the `nativele-vite` Skill, with a local mock API and Playwright 1.64 holding, failing and counting requests. With a different major version of TanStack Query, treat every step as unverified.
|
|
35
|
+
|
|
36
|
+
## Set-up
|
|
37
|
+
|
|
38
|
+
Starting from a Vite app with Nativele already set up (`npx ts-dom skills nativele-vite`: `strict`, the `tsDom({ dirs: ["src/nv"] })` plugin, `"build": "ts-dom check src/nv && vite build"`):
|
|
39
|
+
|
|
40
|
+
1. `npm i @tanstack/query-core`.
|
|
41
|
+
2. Create one `QueryClient` for the whole app in its own module, call `mount()` once, and add the adapter next to it:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// src/query.ts
|
|
45
|
+
import { QueryClient } from "@tanstack/query-core";
|
|
46
|
+
import type { ReadableStore } from "@nativele/ts-dom";
|
|
47
|
+
|
|
48
|
+
export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
49
|
+
queryClient.mount();
|
|
50
|
+
|
|
51
|
+
export function observe<R>(observer: { getCurrentResult(): R; subscribe(fn: (result: R) => void): () => void }): ReadableStore<R> {
|
|
52
|
+
return { get: () => observer.getCurrentResult(), subscribe: (fn) => observer.subscribe(fn) };
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The app owns the client; components import it and never create their own. One client is one cache, so two components that ask for the same key share one request. Without `mount()`, the client does not refetch when the window regains focus or the network comes back (limitation 5). `retry: false` is a choice for this example, not a requirement (limitation 6).
|
|
57
|
+
|
|
58
|
+
3. In each component that shows a query, create the observer as a private field, `bind` it in `connected()`, and copy what the template shows into `state` (Example). `bind` calls the callback once with the current result, then on every change, and unsubscribes when the element is removed.
|
|
59
|
+
4. When the query depends on a prop, build the options from the prop in a function and call `setOptions` in `render` when the prop changed (the `user-detail` component in Example).
|
|
60
|
+
5. For a write, create a `MutationObserver` as a field the same way, call `mutate` from a `data-on-*` handler, invalidate the affected keys in `onSuccess`, and `bind` the mutation observer for its pending and error state.
|
|
61
|
+
|
|
62
|
+
## Example
|
|
63
|
+
|
|
64
|
+
Request helpers (your API; the paths are the app's own):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// src/api.ts
|
|
68
|
+
export interface User {
|
|
69
|
+
id: number;
|
|
70
|
+
name: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
|
74
|
+
const res = await fetch(url, { signal });
|
|
75
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
76
|
+
return (await res.json()) as T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
80
|
+
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
81
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
82
|
+
return (await res.json()) as T;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A list with a fixed key, and a mutation that invalidates it:
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<!-- src/nv/user-list.html -->
|
|
90
|
+
<p data-if="loading">Loading users...</p>
|
|
91
|
+
<p role="alert" data-if="failed">Could not load: {{ error }}</p>
|
|
92
|
+
<p data-if="refetching">Refreshing...</p>
|
|
93
|
+
<ul>
|
|
94
|
+
<template data-each="users">
|
|
95
|
+
<li>{{ name }}</li>
|
|
96
|
+
</template>
|
|
97
|
+
</ul>
|
|
98
|
+
<input data-ref="newName" aria-label="New user" />
|
|
99
|
+
<button type="button" data-on-click="add">Add</button>
|
|
100
|
+
<span data-if="saving">Saving...</span>
|
|
101
|
+
<p role="alert" data-if="saveFailed">Could not save: {{ saveError }}</p>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// src/nv/user-list.ts
|
|
106
|
+
import { MutationObserver, QueryObserver } from "@tanstack/query-core";
|
|
107
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
108
|
+
import { getJson, postJson, type User } from "../api.ts";
|
|
109
|
+
import { observe, queryClient } from "../query.ts";
|
|
110
|
+
import tpl from "./user-list.html.js";
|
|
111
|
+
|
|
112
|
+
export class UserList extends Component(tpl, {
|
|
113
|
+
state: { status: "pending", fetching: false, error: "", users: [] as readonly User[], saving: false, saveError: "" },
|
|
114
|
+
derive: {
|
|
115
|
+
loading: (s) => s.status === "pending",
|
|
116
|
+
failed: (s) => s.status === "error",
|
|
117
|
+
refetching: (s) => s.status === "success" && s.fetching,
|
|
118
|
+
saveFailed: (s) => s.saveError !== "",
|
|
119
|
+
},
|
|
120
|
+
}) {
|
|
121
|
+
#users = new QueryObserver(queryClient, {
|
|
122
|
+
queryKey: ["users"],
|
|
123
|
+
queryFn: ({ signal }) => getJson<User[]>("/api/users", signal),
|
|
124
|
+
});
|
|
125
|
+
#addUser = new MutationObserver(queryClient, {
|
|
126
|
+
mutationFn: (name: string) => postJson<User>("/api/users", { name }),
|
|
127
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
override connected(): void {
|
|
131
|
+
this.bind(observe(this.#users), (r) =>
|
|
132
|
+
this.set({ status: r.status, fetching: r.isFetching, error: r.error?.message ?? "", users: r.data ?? [] }),
|
|
133
|
+
);
|
|
134
|
+
this.bind(observe(this.#addUser), (m) => this.set({ saving: m.isPending, saveError: m.error?.message ?? "" }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
add(): void {
|
|
138
|
+
const name = this.refs.newName.value.trim();
|
|
139
|
+
if (!name) return;
|
|
140
|
+
this.#addUser.mutate(name).then(() => (this.refs.newName.value = ""), () => {});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override render(view: { users: readonly User[] }, changed: ReadonlySet<"users">): void {
|
|
144
|
+
if (changed.has("users")) this.lists.users.replace(view.users, (u) => u.id, (u, { text }) => (text.name = u.name));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
define("user-list", UserList);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A detail whose query key comes from a prop:
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<!-- src/nv/user-detail.html -->
|
|
154
|
+
<p data-if="loading">Loading user {{ userId }}...</p>
|
|
155
|
+
<p role="alert" data-if="failed">Could not load user {{ userId }}: {{ error }}</p>
|
|
156
|
+
<h2 data-if="ready">{{ name }}</h2>
|
|
157
|
+
<small data-if="refetching">Refreshing...</small>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// src/nv/user-detail.ts
|
|
162
|
+
import { QueryObserver } from "@tanstack/query-core";
|
|
163
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
164
|
+
import { getJson, type User } from "../api.ts";
|
|
165
|
+
import { observe, queryClient } from "../query.ts";
|
|
166
|
+
import tpl from "./user-detail.html.js";
|
|
167
|
+
|
|
168
|
+
const userQuery = (id: number) => ({
|
|
169
|
+
queryKey: ["user", id],
|
|
170
|
+
queryFn: ({ signal }: { signal: AbortSignal }) => getJson<User>(`/api/users/${id}`, signal),
|
|
171
|
+
enabled: id > 0,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export class UserDetail extends Component(tpl, {
|
|
175
|
+
props: { userId: 0 },
|
|
176
|
+
state: { status: "pending", fetching: false, error: "", name: "" },
|
|
177
|
+
derive: {
|
|
178
|
+
loading: (s) => s.status === "pending",
|
|
179
|
+
failed: (s) => s.status === "error",
|
|
180
|
+
ready: (s) => s.status === "success",
|
|
181
|
+
refetching: (s) => s.status === "success" && s.fetching,
|
|
182
|
+
},
|
|
183
|
+
}) {
|
|
184
|
+
#user = new QueryObserver(queryClient, userQuery(0));
|
|
185
|
+
|
|
186
|
+
override connected(): void {
|
|
187
|
+
this.bind(observe(this.#user), (r) =>
|
|
188
|
+
this.set({ status: r.status, fetching: r.isFetching, error: r.error?.message ?? "", name: r.data?.name ?? "" }),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
override render(view: { userId: number }, changed: ReadonlySet<"userId">): void {
|
|
193
|
+
if (changed.has("userId")) this.#user.setOptions(userQuery(view.userId));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
define("user-detail", UserDetail);
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The parent passes the prop, can remove the detail, and refreshes everything:
|
|
200
|
+
|
|
201
|
+
```html
|
|
202
|
+
<!-- src/nv/app-shell.html -->
|
|
203
|
+
<button type="button" data-on-click="refreshAll">Refresh all</button>
|
|
204
|
+
<user-list></user-list>
|
|
205
|
+
<select data-ref="pick" data-on-change="pickUser" aria-label="User">
|
|
206
|
+
<option value="1">1</option>
|
|
207
|
+
<option value="2">2</option>
|
|
208
|
+
</select>
|
|
209
|
+
<button type="button" data-on-click="toggle">Toggle detail</button>
|
|
210
|
+
<div data-if="showDetail">
|
|
211
|
+
<user-detail data-ref="detail"></user-detail>
|
|
212
|
+
</div>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// src/nv/app-shell.ts
|
|
217
|
+
import { Component, define } from "@nativele/ts-dom";
|
|
218
|
+
import { queryClient } from "../query.ts";
|
|
219
|
+
import "./user-list.ts";
|
|
220
|
+
import "./user-detail.ts";
|
|
221
|
+
import tpl from "./app-shell.html.js";
|
|
222
|
+
|
|
223
|
+
export class AppShell extends Component(tpl, { state: { userId: 1, showDetail: true } }) {
|
|
224
|
+
pickUser(): void {
|
|
225
|
+
this.set({ userId: Number(this.refs.pick.value) });
|
|
226
|
+
}
|
|
227
|
+
toggle(): void {
|
|
228
|
+
this.set((s) => ({ showDetail: !s.showDetail }));
|
|
229
|
+
}
|
|
230
|
+
refreshAll(): void {
|
|
231
|
+
void queryClient.invalidateQueries();
|
|
232
|
+
}
|
|
233
|
+
override render(view: { userId: number }, changed: ReadonlySet<"userId">): void {
|
|
234
|
+
if (changed.has("userId")) this.refs.detail.userId = view.userId;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
define("app-shell", AppShell);
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`src/main.ts` imports `./nv/app-shell.ts`, and `index.html` has `<app-shell></app-shell>`. What the example does, as verified:
|
|
241
|
+
|
|
242
|
+
- On load, each component shows its loading block, then the data; the list is filled with `replace()`. One request per query.
|
|
243
|
+
- A refetch (invalidation, focus, a remount of stale data) keeps the data on screen and shows the `refetching` block until it ends.
|
|
244
|
+
- Changing `userId` switches the query: a key never loaded shows the loading block, not the previous user's data; a key already in the cache shows its data at once and refetches it.
|
|
245
|
+
- A failed request shows the error block (`Could not load user 99: HTTP 404`).
|
|
246
|
+
- Clicking Add shows `Saving...`, then the list is refetched once and shows the new user; a failed save shows its error, keeps the input and refetches nothing.
|
|
247
|
+
|
|
248
|
+
## Known limitations
|
|
249
|
+
|
|
250
|
+
1. **One observer per component instance; one client per app.** Create the observer as a field and `bind` it in `connected()`, not in the constructor: `bind` releases the subscription on removal and subscribes again when the element comes back (see the lists guide on connecting and subscriptions). A module-level observer shared by several components also works (each `bind` adds a listener; when the last one is removed the observer stops, and it starts again when one comes back), but only when its options do not depend on any one component: `setOptions` from one component would switch the query for all of them. Two components with their own observers on the same key share one request through the client's cache.
|
|
251
|
+
2. **Removing the element stops everything for it.** After the element leaves the page, invalidation and window focus do not refetch its query, and a request that finishes later does not reach the element. Pass `signal` from the query function's context to `fetch`: then removing the last component that observes a query while it is fetching aborts the request. Without `signal`, the request runs to the end and fills the cache, but the removed element still gets no update. Putting the element back (the same element, as `data-if` does) shows the cached data at once and refetches it (with the default `staleTime` of 0, cached data is always stale).
|
|
252
|
+
3. **Keys from props go through `setOptions` in `render`.** Guard it with `changed.has("userId")`. A prop change while the element is removed sends no request. When a parent shows the element again with `data-if` and gives it a new prop in the same update, the element connects first, with its old prop, and fetches that key before `render` switches to the new one; so when hiding it, also reset the prop to a value whose query is disabled (`productId: 0` with `enabled: id > 0`), and only the new key is fetched. Use `enabled` for values that must not be fetched (here `id > 0`); a disabled query stays `status: "pending"`, so the loading block shows until a real id arrives. Show it differently if that matters (for example derive `loading` from `userId > 0` as well).
|
|
253
|
+
4. **Map `status` and `isFetching`, not `isLoading`.** Before the first subscription the result is `status: "pending"` with `fetchStatus: "idle"` (`isLoading` is `false`), and `bind` calls the callback with that result first; subscribing starts the fetch and calls it again at once with `fetchStatus: "fetching"`. Deriving `loading` from `status === "pending"` shows the loading block from the first render on. Copy only what the template shows into state.
|
|
254
|
+
5. **Call `queryClient.mount()` once.** Without it, a `visibilitychange` on the window (focus) does not refetch active stale queries; with it, it does.
|
|
255
|
+
6. **Errors wait for retries by default.** With the default `retry`, a failing query showed its error after about 7 seconds (`failureCount` 4: the first attempt and three retries), and the loading block stays until then. Set `retry` in the client's `defaultOptions` or per query to what the app needs; tests that check error states need it low or off.
|
|
256
|
+
7. **Handle the promise of `mutate`.** `mutate()` returns a promise that rejects when the mutation fails; without a rejection handler the page reports an uncaught `Error: HTTP 500`. The `MutationObserver` result (bound above) already carries the error for the view, so the handler can be empty.
|
|
257
|
+
8. **Arrays from `data` are read-only in state.** Declare list state as `readonly T[]` (`[] as readonly User[]`) and pass `r.data ?? []` as it is; the view and `render` receive it read-only (see the state guide).
|
|
258
|
+
9. Not verified: infinite queries, `placeholderData` and keeping previous data while switching keys, `refetchInterval`, persistence, hydration and server rendering, offline and paused mutations, optimistic updates, the TanStack devtools, the framework packages, and versions of `@tanstack/query-core` other than 5.106.
|
|
259
|
+
|
|
260
|
+
## Verify
|
|
261
|
+
|
|
262
|
+
Run in the project:
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
npx ts-dom check src/nv # expect: ts-dom check: no issues
|
|
266
|
+
npx ts-dom doctor src/nv # expect: ts-dom doctor: the set-up looks right (info lines are suggestions)
|
|
267
|
+
npm run build # expect: ts-dom check: no issues, then ✓ built
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Then check in a browser against the dev server, without external network: point the queries at a local mock API (for example a Vite plugin with `configureServer` middleware, or the app's own backend in development), and make the states deterministic by holding requests in the test instead of adding delays. With Playwright, `page.route("**/api/**", ...)` can record every request, keep a matching one waiting until the test releases it with `route.continue()`, or answer it with `route.fulfill({ status: 500 })`. Check:
|
|
271
|
+
|
|
272
|
+
- while a request is held, the loading block shows; after release, the data and the list items show, with one request per query;
|
|
273
|
+
- with data on screen, invalidating holds a refetch: the `refetching` block shows and the data stays;
|
|
274
|
+
- changing the prop sends a request for the new key and shows its loading block, not the previous data;
|
|
275
|
+
- after the component is removed, invalidation and a `visibilitychange` event on `window` send no request for its query, and a held request released after removal does not change the removed element (keep a reference to it); with `signal` passed to `fetch`, a request held when the component is removed fails with `requestfailed`, as long as no other component still observes the same key (another observer keeps the query, and its request, alive);
|
|
276
|
+
- after putting the element back, it shows the cached data and its query refetches;
|
|
277
|
+
- a held mutation shows its pending block; after release the list is refetched once and shows the new item; a failed mutation shows its error;
|
|
278
|
+
- no console errors or warnings, apart from the browser's `Failed to load resource` line for a status you return on purpose.
|
|
279
|
+
|
|
280
|
+
## Troubleshooting
|
|
281
|
+
|
|
282
|
+
| Symptom | Cause | Fix |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| Queries never refetch when the window regains focus | `queryClient.mount()` was not called | call it once where the client is created |
|
|
285
|
+
| The error block shows only after several seconds | the default `retry` (three retries with growing delays) | set `retry` in `defaultOptions` or per query |
|
|
286
|
+
| Console: uncaught `Error: HTTP 500` after a failed mutation | the promise from `mutate()` has no rejection handler | add one: `.then(onDone, () => {})` |
|
|
287
|
+
| A component with no id shows its loading block forever and sends no request | `enabled` is false for that value, so the query stays `pending` | set the prop, or derive the view from the prop as well |
|
|
288
|
+
| The prop is set but the loading block stays and no request is sent | `setOptions` is not called in `render`, so the observer keeps its initial (disabled) options | call `this.#user.setOptions(userQuery(view.userId))` when `changed.has("userId")` |
|
|
289
|
+
| After the element is removed and put back, it never refetches or updates | the observer was subscribed in a field or the constructor and unsubscribed in `disconnected()`, so nothing subscribes again | subscribe with `this.bind(observe(observer), ...)` in `connected()` |
|
|
290
|
+
| `ts-dom check`: `[missing-binding] "isFetching" is used by {{ isFetching }} ...`, followed by `TS7006: Parameter 'r' implicitly has an 'any' type` in the `bind` callback | the template names a result field that is not in `state` or `derive`; the second error follows from the first | copy the field into state in the `bind` callback, or derive it |
|
|
291
|
+
|
|
292
|
+
## See also
|
|
293
|
+
|
|
294
|
+
- `bind`, stores and read-only sources: https://github.com/Pixmi/nativele/blob/main/docs/components.md#state-store-when-not-using-state
|
|
295
|
+
- `state`, `derive`, `render` and `changed`: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-and-view-state-optional
|
|
296
|
+
- Props from the parent: https://github.com/Pixmi/nativele/blob/main/docs/components.md#data-flow-props-down-events-up
|
|
297
|
+
- Loading and error states without a library: https://github.com/Pixmi/nativele/blob/main/docs/recipes.md#loading-and-error-states
|
|
298
|
+
- Copying store data into state: https://github.com/Pixmi/nativele/blob/main/docs/recipes.md#showing-data-from-a-store
|
|
299
|
+
- Lists with `replace()`: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#items-as-plain-elements-replace
|
|
300
|
+
- Lists of item components (`items = ...`): https://github.com/Pixmi/nativele/blob/main/docs/lists.md#items-as-components-items--
|
|
301
|
+
- Why subscriptions belong in `connected()`: https://github.com/Pixmi/nativele/blob/main/docs/lists.md#moving-items-connecting-and-subscriptions
|
|
302
|
+
- Setting up Vite: `npx ts-dom skills nativele-vite`
|