@madojs/mado 0.6.1 → 0.7.0

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.
@@ -4,6 +4,7 @@
4
4
  > делают при генерации Mado-кода. И как их исправлять.
5
5
 
6
6
  Этот документ — для **двух аудиторий**:
7
+
7
8
  1. **AI-агентов в IDE**, которые читают `AGENTS.md` / `.cursorrules` / `.github/copilot-instructions.md`. Здесь больше деталей по типичным граблям.
8
9
  2. **Людей**, которые получили от AI код с этими ошибками и не понимают, что не так.
9
10
 
@@ -17,19 +18,20 @@
17
18
  const count = signal(0);
18
19
 
19
20
  // ❌ AI генерирует это часто
20
- html`<div>Count: ${count() * 2}</div>`
21
+ html`<div>Count: ${count() * 2}</div>`;
21
22
  // → Отрисует "Count: 0", и больше никогда не обновится.
22
23
  // count() прочитан один раз в момент создания TemplateResult.
23
24
 
24
25
  // ✅ Правильно — функция-геттер
25
- html`<div>Count: ${() => count() * 2}</div>`
26
+ html`<div>Count: ${() => count() * 2}</div>`;
26
27
  // → Mado создаст effect() на эту функцию, при изменении count перерисует.
27
28
 
28
29
  // ✅ Тоже правильно — сам сигнал является функцией
29
- html`<div>Count: ${count}</div>`
30
+ html`<div>Count: ${count}</div>`;
30
31
  ```
31
32
 
32
33
  **Правило:**
34
+
33
35
  - Если в `${...}` есть **выражение** (что-то делает с сигналом) — оборачивай в `() => ...`.
34
36
  - Если в `${...}` **сам сигнал** — можно как есть.
35
37
 
@@ -45,10 +47,10 @@ html`<div>Count: ${count}</div>`
45
47
  const loading = signal(false);
46
48
 
47
49
  // ❌ Это setAttribute("disabled", "false") — DOM воспринимает это как disabled
48
- html`<button disabled=${loading()}>Save</button>`
50
+ html`<button disabled=${loading()}>Save</button>`;
49
51
 
50
52
  // ✅ Правильно — boolean-биндинг (toggle attribute)
51
- html`<button ?disabled=${loading}>Save</button>`
53
+ html`<button ?disabled=${loading}>Save</button>`;
52
54
  ```
53
55
 
54
56
  **Правило для атрибутов:**
@@ -86,6 +88,7 @@ component("x-counter", () => {
86
88
  ```
87
89
 
88
90
  **Ключевые отличия:**
91
+
89
92
  - Нет хуков, нет правил хуков.
90
93
  - `signal()` можно создавать где угодно — в setup, в effect, в обработчике.
91
94
  - `effect()` сам видит, что прочитал — не нужен dependency array.
@@ -93,7 +96,7 @@ component("x-counter", () => {
93
96
 
94
97
  ---
95
98
 
96
- ## Pitfall #4: `useEffect(() => { ... return cleanup })`
99
+ ## Pitfall #4: `useEffect(() => { ... return cleanup })`
97
100
 
98
101
  **Симптом:** AI пишет `return cleanup` в effect, ожидая что это сработает как в React.
99
102
 
@@ -172,11 +175,20 @@ import { Home } from "./pages/home.js";
172
175
 
173
176
  ```ts
174
177
  // ❌ Работает, но не keyed: пересоздаёт DOM на каждое изменение
175
- html`<ul>${() => items().map(t => html`<li>${t.name}</li>`)}</ul>`
178
+ html`<ul>
179
+ ${() => items().map((t) => html`<li>${t.name}</li>`)}
180
+ </ul>`;
176
181
 
177
182
  // ✅ Правильно: each() с key-функцией
178
183
  import { each } from "@madojs/mado";
179
- html`<ul>${() => each(items(), t => t.id, t => html`<li>${t.name}</li>`)}</ul>`
184
+ html`<ul>
185
+ ${() =>
186
+ each(
187
+ items(),
188
+ (t) => t.id,
189
+ (t) => html`<li>${t.name}</li>`,
190
+ )}
191
+ </ul>`;
180
192
  ```
181
193
 
182
194
  **Правило:** всегда используй `each()` для списков из массивов с устойчивыми ID. `.map()` оставь только для статичных списков.
@@ -191,15 +203,15 @@ html`<ul>${() => each(items(), t => t.id, t => html`<li>${t.name}</li>`)}</ul>`
191
203
  const count = signal(0);
192
204
 
193
205
  // ❌ Нет такого API
194
- count.value
195
- count.value = 5
196
- count.get()
206
+ count.value;
207
+ count.value = 5;
208
+ count.get();
197
209
 
198
210
  // ✅ Правильно
199
- count() // прочитать
200
- count.set(5) // записать
201
- count.update(n => n + 1)
202
- count.peek() // прочитать без подписки
211
+ count(); // прочитать
212
+ count.set(5); // записать
213
+ count.update((n) => n + 1);
214
+ count.peek(); // прочитать без подписки
203
215
  ```
204
216
 
205
217
  ---
@@ -220,7 +232,7 @@ component("x-app", ({ host }) => {
220
232
  });
221
233
 
222
234
  component("x-child", ({ host }) => {
223
- const api = inject(host, ApiCtx); // signal<value>
235
+ const api = inject(host, ApiCtx); // signal<value>
224
236
  return () => html`...`;
225
237
  });
226
238
  ```
@@ -242,6 +254,7 @@ if (typeof window !== "undefined") { ... } // в Mado window есть ВСЕГ
242
254
  Mado **не делает SSR с гидрацией**. На сервере код не выполняется — есть только `bake` (статический prerender на build) и edge-prerender. Оба заменяют user code на linkedom-окружение, но это **только** для генерации HTML с meta-тегами, не для выполнения логики страницы.
243
255
 
244
256
  Это значит:
257
+
245
258
  - ✅ `window`, `document`, `location`, `fetch` — доступны без проверок.
246
259
  - ❌ Не пиши код, который пытается «универсально работать на сервере и клиенте».
247
260
  - ❌ Не используй паттерны Next.js (`getServerSideProps`, `headers()`).
@@ -259,7 +272,7 @@ const f = useForm({ resolver: zodResolver(schema) });
259
272
  // ✅ Правильно: HTML5-валидация атрибутами
260
273
  const f = useForm({
261
274
  email: { required: true, type: "email" },
262
- age: { required: true, type: "number", min: 18 },
275
+ age: { required: true, type: "number", min: 18 },
263
276
  });
264
277
 
265
278
  // ✅ Или кастомная функция, если HTML5 не хватает
@@ -287,11 +300,13 @@ Mado использует **Shadow DOM + `css\`\`` + CSS variables**. Глоба
287
300
 
288
301
  ```ts
289
302
  // Light-DOM page/screen компонент, Tailwind-классы работают
290
- component("x-admin-page", () => () => html`
291
- <section class="bg-white shadow-lg rounded-lg p-4">
292
- ...
293
- </section>
294
- `, { shadow: false });
303
+ component(
304
+ "x-admin-page",
305
+ () => () => html`
306
+ <section class="bg-white shadow-lg rounded-lg p-4">...</section>
307
+ `,
308
+ { shadow: false },
309
+ );
295
310
 
296
311
  // Shadow-DOM компонент (default) — Tailwind НЕ работает.
297
312
  // Используй css`` или ::part() для внешней стилизации.
@@ -300,7 +315,7 @@ component("x-button", () => () => html`<button><slot></slot></button>`, {
300
315
  button {
301
316
  background: var(--button-bg, #2563eb);
302
317
  color: white;
303
- padding: .5rem 1rem;
318
+ padding: 0.5rem 1rem;
304
319
  border-radius: 6px;
305
320
  }
306
321
  `,
@@ -333,6 +348,7 @@ Mado.signal(0);
333
348
  **Симптом:** AI предлагает `npm install lodash` / `npm install date-fns` / etc.
334
349
 
335
350
  Mado — **zero runtime deps** by design. Если AI хочет добавить:
351
+
336
352
  - **lodash** → используй нативный JS (`Object.entries`, `Array.prototype`, `structuredClone`);
337
353
  - **date-fns** → используй `Intl.DateTimeFormat` и `Intl.RelativeTimeFormat`;
338
354
  - **uuid** → `crypto.randomUUID()`;
@@ -351,19 +367,27 @@ Mado — **zero runtime deps** by design. Если AI хочет добавит
351
367
  // ❌ Работает, но плохо масштабируется и усложняет cleanup
352
368
  page({
353
369
  view: () => html`
354
- <style>.panel { padding: 1rem; }</style>
370
+ <style>
371
+ .panel {
372
+ padding: 1rem;
373
+ }
374
+ </style>
355
375
  <section class="panel">...</section>
356
376
  `,
357
377
  });
358
378
 
359
379
  // ✅ Правильно: стили компонента через css``
360
- component("x-admin-panel", () => () => html`
361
- <section class="panel">...</section>
362
- `, {
363
- styles: css`
364
- .panel { padding: 1rem; }
365
- `,
366
- });
380
+ component(
381
+ "x-admin-panel",
382
+ () => () => html` <section class="panel">...</section> `,
383
+ {
384
+ styles: css`
385
+ .panel {
386
+ padding: 1rem;
387
+ }
388
+ `,
389
+ },
390
+ );
367
391
  ```
368
392
 
369
393
  Для backend-admin route/page экранов часто уместен `shadow: false`, чтобы
@@ -380,10 +404,10 @@ prefetch'ится.
380
404
 
381
405
  ```ts
382
406
  // ❌ Обычная ссылка: браузер сделает full reload
383
- html`<a href="/tickets/42">Open</a>`
407
+ html`<a href="/tickets/42">Open</a>`;
384
408
 
385
409
  // ✅ SPA-навигация: router() перехватит click даже через Shadow DOM
386
- html`<a href="/tickets/42" data-link>Open</a>`
410
+ html`<a href="/tickets/42" data-link>Open</a>`;
387
411
  ```
388
412
 
389
413
  Mado ищет ссылку через `event.composedPath()`, поэтому `data-link` работает
@@ -399,7 +423,10 @@ Mado ищет ссылку через `event.composedPath()`, поэтому `da
399
423
 
400
424
  ```ts
401
425
  // ❌ Нет lifecycle cleanup, будет dev-warning
402
- const tickets = resource(() => "tickets", () => api.listTickets());
426
+ const tickets = resource(
427
+ () => "tickets",
428
+ () => api.listTickets(),
429
+ );
403
430
 
404
431
  component("x-tickets", () => {
405
432
  return () => html`${() => tickets.data()?.length ?? 0}`;
@@ -407,7 +434,10 @@ component("x-tickets", () => {
407
434
 
408
435
  // ✅ Создавай resource внутри setup компонента
409
436
  component("x-tickets", () => {
410
- const tickets = resource(() => "tickets", () => api.listTickets());
437
+ const tickets = resource(
438
+ () => "tickets",
439
+ () => api.listTickets(),
440
+ );
411
441
  return () => html`${() => tickets.data()?.length ?? 0}`;
412
442
  });
413
443
  ```
@@ -426,7 +456,7 @@ disconnect компонента.
426
456
  const view = signal(html`<x-home></x-home>`);
427
457
 
428
458
  // ✅ Нормальный паттерн: вложенный TemplateResult можно возвращать из child-binding
429
- html`${view}`
459
+ html`${view}`;
430
460
  ```
431
461
 
432
462
  Начиная с v0.3 это закреплено регрессиями: при замене child-binding Mado
@@ -442,16 +472,23 @@ dispose'ит вложенные template instances/effects рекурсивно.
442
472
 
443
473
  ```ts
444
474
  // ❌ .page-head объявлен глобально, но x-dashboard по умолчанию Shadow DOM
445
- component("x-dashboard", () => () => html`
446
- <header class="page-head">...</header>
447
- <div class="metric-grid">...</div>
448
- `);
475
+ component(
476
+ "x-dashboard",
477
+ () => () => html`
478
+ <header class="page-head">...</header>
479
+ <div class="metric-grid">...</div>
480
+ `,
481
+ );
449
482
 
450
483
  // ✅ Page/layout/admin-shell компоненты часто должны быть Light DOM
451
- component("x-dashboard", () => () => html`
452
- <header class="page-head">...</header>
453
- <div class="metric-grid">...</div>
454
- `, { shadow: false });
484
+ component(
485
+ "x-dashboard",
486
+ () => () => html`
487
+ <header class="page-head">...</header>
488
+ <div class="metric-grid">...</div>
489
+ `,
490
+ { shadow: false },
491
+ );
455
492
  ```
456
493
 
457
494
  Правило: Shadow DOM — для leaf widgets и slot-based layouts, Light DOM — для
@@ -462,24 +499,124 @@ Shadow DOM; при `shadow: false` это обычный элемент.
462
499
 
463
500
  ---
464
501
 
502
+ ## Pitfall #20: `host.getAttribute()` в render = не реактивно
503
+
504
+ **Симптом:** внешний вид компонента не обновляется при изменении атрибута родителем.
505
+
506
+ ```ts
507
+ // ❌ host.getAttribute() в render-функции читается один раз, но
508
+ // render перезапускается только при изменении его собственных сигналов.
509
+ // Внешние изменения атрибута не триггерят перерисовку.
510
+ component("x-badge", ({ host }) => () => {
511
+ const variant = host.getAttribute("variant") ?? "default";
512
+ return html`<span class=${variant}>...</span>`;
513
+ });
514
+
515
+ // ✅ Правильно: ctx.attr() — возвращает реактивный Signal<string>
516
+ component("x-badge", ({ attr }) => {
517
+ const variant = attr("variant", "default");
518
+ return () => html`<span class=${() => `badge-${variant()}`}>...</span>`;
519
+ });
520
+ ```
521
+
522
+ **Правило:** никогда не читайте `host.getAttribute()` или `host.hasAttribute()` внутри
523
+ render-функции для значений, которые могут измениться снаружи. Используйте `ctx.attr()` —
524
+ он возвращает Signal, который автоматически обновляется через `attributeChangedCallback`.
525
+
526
+ ---
527
+
528
+ ## Pitfall #21: Shadow DOM `<button>` не сабмитит формы
529
+
530
+ **Симптом:** клик по `<x-button type="submit">` внутри `<form>` ничего не делает.
531
+
532
+ `<button>` внутри Shadow DOM не участвует в алгоритме form-owner для
533
+ `<form>` в Light DOM — это ограничение спецификации, не баг Mado.
534
+
535
+ ```ts
536
+ // ❌ Внутренняя <button type="submit"> не может триггерить родительскую <form>
537
+ component("x-button", ({ host }) => {
538
+ return () => html`<button type="submit"><slot></slot></button>`;
539
+ });
540
+
541
+ // ✅ Мост через requestSubmit()
542
+ component("x-button", ({ host, attr }) => {
543
+ const disabled = attr("disabled");
544
+
545
+ const handleClick = () => {
546
+ const typeAttr = host.getAttribute("type");
547
+ if (typeAttr === "button" || typeAttr === "reset") return;
548
+ const form = host.closest("form");
549
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
550
+ };
551
+
552
+ return () => html`
553
+ <button ?disabled=${() => disabled() !== ""} @click=${handleClick}>
554
+ <slot></slot>
555
+ </button>
556
+ `;
557
+ });
558
+ ```
559
+
560
+ Подробнее: [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
561
+
562
+ ---
563
+
564
+ ## Pitfall #22: `useForm()` с Shadow DOM кастомными input
565
+
566
+ **Симптом:** `form.onInput` получает `undefined` для name/value от `<x-input>`.
567
+
568
+ Когда Shadow DOM input диспатчит `input` событие, браузер ретаргетирует
569
+ `e.target` с внутреннего `<input>` на host `<x-input>`. Но `<x-input>`
570
+ (HTMLElement) не имеет `.name` или `.value` — поэтому `useForm` ничего не получает.
571
+
572
+ ```ts
573
+ // ❌ Нет proxy-свойств — useForm тихо игнорирует события
574
+ component("x-input", ({ host, attr }) => {
575
+ const name = attr("name", "");
576
+ return () => html`<input name=${name} />`;
577
+ });
578
+
579
+ // ✅ Добавить proxy-свойства для совместимости с useForm
580
+ component("x-input", ({ host, attr }) => {
581
+ const name = attr("name", "");
582
+
583
+ Object.defineProperty(host, "name", {
584
+ get: () => host.getAttribute("name") ?? "",
585
+ configurable: true,
586
+ });
587
+ Object.defineProperty(host, "value", {
588
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
589
+ configurable: true,
590
+ });
591
+
592
+ return () => html`<input name=${name} />`;
593
+ });
594
+ ```
595
+
596
+ Подробнее: [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
597
+
598
+ ---
599
+
465
600
  ## Cheat-sheet для AI
466
601
 
467
- | Если хочешь сделать… | Правильно в Mado |
468
- |---|---|
469
- | `useState(0)` | `signal(0)` |
470
- | `useEffect(() => {...}, [a, b])` | `effect(() => {...})` (auto-deps) |
471
- | `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)` |
472
- | `useMemo(() => x, [a])` | `computed(() => x)` |
473
- | `useCallback(fn, [])` | обычная функция |
474
- | `useContext(Ctx)` | `inject(host, Ctx)` |
475
- | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
476
- | `useMutation(fn)` | `mutation(fn, { invalidates: [...] })` |
477
- | `useRouter().push('/')` | `navigate('/')` |
478
- | `useRouter().query.q` | `queryParam('q')` |
479
- | `<input value={v} onChange={...}>` | `<input .value=${v} @input=${...}>` |
480
- | `{items.map(x => ...)}` | `${() => each(items, x => x.id, x => ...)}` |
481
- | `useForm({ resolver: zodResolver })` | `useForm({...}, { validate: (v) => ... })` |
482
- | `class extends HTMLElement` | `component('x-name', setup)` |
483
- | `@customElement('x')` | `component('x-name', setup)` |
602
+ | Если хочешь сделать… | Правильно в Mado |
603
+ | ------------------------------------- | ------------------------------------------- |
604
+ | `useState(0)` | `signal(0)` |
605
+ | `useEffect(() => {...}, [a, b])` | `effect(() => {...})` (auto-deps) |
606
+ | `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)` |
607
+ | `useMemo(() => x, [a])` | `computed(() => x)` |
608
+ | `useCallback(fn, [])` | обычная функция |
609
+ | `useContext(Ctx)` | `inject(host, Ctx)` |
610
+ | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
611
+ | `useMutation(fn)` | `mutation(fn, { invalidates: [...] })` |
612
+ | `useRouter().push('/')` | `navigate('/')` |
613
+ | `useRouter().query.q` | `queryParam('q')` |
614
+ | `<input value={v} onChange={...}>` | `<input .value=${v} @input=${...}>` |
615
+ | `{items.map(x => ...)}` | `${() => each(items, x => x.id, x => ...)}` |
616
+ | `useForm({ resolver: zodResolver })` | `useForm({...}, { validate: (v) => ... })` |
617
+ | `class extends HTMLElement` | `component('x-name', setup)` |
618
+ | `@customElement('x')` | `component('x-name', setup)` |
619
+ | `host.getAttribute('x')` в render | `ctx.attr('x', default)` (реактивно) |
620
+ | `jsonFetcher()` с авторизацией | `apiFetcher()` (прикрепляет Bearer токен) |
484
621
 
485
622
  Если что-то не подходит из этого списка — открой `src/` и **прочитай 500 строк**. Это серьёзно. Mado специально маленький, чтобы быть читаемым.
@@ -1,56 +1,57 @@
1
- # LLM zero-history test
1
+ # Тест LLM с нулевой историей
2
2
 
3
- This document defines a practical validation test for Mado.
3
+ Этот документ определяет практический валидационный тест для Mado.
4
4
 
5
- The question is not "can an LLM generate frontend code?" It can. The question is:
6
- can a fresh LLM write idiomatic Mado without falling back to React-shaped code?
5
+ Вопрос не в том, «может ли LLM генерировать фронтенд-код?» может. Вопрос в том:
6
+ может ли свежая LLM написать идиоматичный Mado-код без скатывания в React-подобные
7
+ паттерны?
7
8
 
8
- ## Allowed context
9
+ ## Допустимый контекст
9
10
 
10
- For the first pass, give the agent only:
11
+ При первом проходе предоставьте агенту только:
11
12
 
12
13
  - `AGENTS.md`
13
14
  - `README.md`
14
- - `docs/ru/07-llm-pitfalls.md`
15
- - `examples/basic/README.md` if a minimal API tour is needed
16
- - specific `examples/showcase/**` files only when the agent asks for a larger app pattern
15
+ - `docs/en/07-llm-pitfalls.md`
16
+ - `examples/basic/README.md` если нужен минимальный обзор API
17
+ - конкретные файлы из `examples/showcase/**` только если агент сам попросит паттерн более крупного приложения
17
18
 
18
- The agent may search targeted APIs in `src/` when blocked, but should not load
19
- the whole framework into context.
19
+ Агент может искать целевые API в `src/` когда заблокирован, но не должен
20
+ загружать весь фреймворк в контекст.
20
21
 
21
- ## Task
22
+ ## Задание
22
23
 
23
- Build `examples/tickets`: a small ticket-admin SPA for a solo/backend developer.
24
+ Построить `examples/tickets`: маленький ticket-admin SPA для соло/бекенд-разработчика.
24
25
 
25
- Required behavior:
26
+ Требуемое поведение:
26
27
 
27
- - routes: `/`, `/tickets`, `/tickets/new`, `/tickets/:id`, `*`;
28
- - in-memory mock API with realistic async delays;
29
- - list page with `resource()`, `queryParam()` search/status filters, `computed()`,
30
- and keyed `each()` rows;
31
- - create and edit flows with `useForm()` + `mutation()` + `invalidates`;
32
- - local UI state with `signal()`;
33
- - slotted shell, metric, and badge components for a more realistic admin UI;
34
- - smoke test importing the built example.
28
+ - маршруты: `/`, `/tickets`, `/tickets/new`, `/tickets/:id`, `*`;
29
+ - in-memory mock API с реалистичными асинхронными задержками;
30
+ - страница списка с `resource()`, `queryParam()` фильтрами поиска/статуса,
31
+ `computed()` и `each()` с ключами по строкам;
32
+ - сценарии создания и редактирования с `useForm()` + `mutation()` + `invalidates`;
33
+ - локальный UI-стейт через `signal()`;
34
+ - slotted shell, metric и badge компоненты для более реалистичного admin UI;
35
+ - smoke-тест, импортирующий собранный пример.
35
36
 
36
- ## Failure checklist
37
+ ## Чеклист ошибок
37
38
 
38
- Look for these after implementation:
39
+ Ищите следующее после реализации:
39
40
 
40
- - JSX, `useState`, `useEffect`, `ref`, `$state`, or class-style components;
41
- - `${signal()}` or `${signal() + 1}` where a reactive child thunk is required;
42
- - `disabled=${...}` instead of `?disabled=${...}`;
43
- - dynamic lists rendered with unkeyed array mapping instead of `each()`;
44
- - browser ESM imports without `.js`;
45
- - `resource()` created outside component setup;
46
- - new runtime dependencies or new public framework APIs.
41
+ - JSX, `useState`, `useEffect`, `ref`, `$state` или class-style компоненты;
42
+ - `${signal()}` или `${signal() + 1}` где требуется реактивный child thunk;
43
+ - `disabled=${...}` вместо `?disabled=${...}`;
44
+ - динамические списки, отрендеренные через unkeyed `.map()` вместо `each()`;
45
+ - ESM-импорты в браузере без `.js`;
46
+ - `resource()`, созданный вне component setup;
47
+ - новые runtime-зависимости или новые публичные API фреймворка.
47
48
 
48
- ## Result notes
49
+ ## Заметки по результатам
49
50
 
50
- The current `examples/tickets` implementation did not require new public APIs or
51
- runtime dependencies.
51
+ Текущая реализация `examples/tickets` не потребовала новых публичных API или
52
+ runtime-зависимостей.
52
53
 
53
- The main documentation pressure point remains lifecycle: older examples can make
54
- it look acceptable to create `resource()` directly in `page.view()`. The tickets
55
- example uses page-level wrapper components instead, so resources are registered
56
- inside component setup and clean up with the component.
54
+ Основная болевая точка в документации остается lifecycle: старые примеры могут
55
+ создать впечатление, что создание `resource()` прямо в `page.view()` допустимо.
56
+ Пример tickets использует page-level wrapper-компоненты вместо этого, поэтому
57
+ ресурсы регистрируются внутри component setup и очищаются вместе с компонентом.