Imported from ex2mple/frontend-smart-sandbox (
tools/sandbox/templates/change-detection/AGENTS.md). Install upstream withnpx skills add ex2mple/frontend-smart-sandbox --skill change-detection. Copyright stays with the author.
{{title}} — sandbox
Учебная песочница. Ты — и тьютор, и инженер: объясняй как работает change detection и сразу реализуй эксперименты в этом скаффолде. Общие правила проекта: ../../AGENTS.md
Что здесь изучается
Angular запускает Change Detection (CD) — обход дерева компонентов. Приложение zoneless (без zone.js): CD планируется только когда меняется signal, срабатывает обработчик события из шаблона или вызван markForCheck(). Таймеры и промисы сами по себе CD не запускают. При ChangeDetectionStrategy.Default Angular проверяет компонент на каждом проходе, дошедшем до него; при OnPush — только когда изменился input, сработал signal внутри компонента или явно вызван markForCheck().
Демка не просто считает проверки — она записывает целый CD-проход как список шагов «карточка X: checked/skipped + почему» и даёт проиграть его по шагам, подсвечивая дерево компонентов зелёным (checked) или серым (skipped-with-reason).
Как устроена демка (архитектура — replay model)
- Реальный триггер, записанный проход. Каждая кнопка-триггер (клик снаружи / изменить
sharedInput/setTimeout/ клик внутри карточки) выполняет НАСТОЯЩЕЕ действие (пишет в signal), которое Angular реально превращает в CD-проход. Демка не подделывает, кто был checked/skipped — это следует изrecordCheck(), вызываемого ПРЯМО ИЗ ШАБЛОНА каждой карточки (../../shared/learningдаётRunRecorder). - Как ловится «checked».
recordCheck()каждой карточки эмититoutput() checked(id)синхронно (не пишет в signal!) при каждой РЕАЛЬНОЙ (не self-inflicted) проверке шаблона. Родитель копит эти id в обычномSet(не signal) — запись в plain-объект во время CD абсолютно безопасна, в отличие от записи в signal. - Окно наблюдения одного прохода. При клике по триггеру родитель открывает окно (
passOpen = true, очищаетSet) и планирует закрытие черезsetTimeout(…, 0)— это гарантирует, что настоящий CD-проход (и любые «эхо»-проходы от queueMicrotask-флашей самих карточек) точно улёгся, прежде чем окно закроется и читается итог. Как «checked» определяется: карточка вSet→ checked; остальные три — skipped. - Причина (reason) — не измеряется API, а следует из известных правил CD, применённых к ИЗМЕРЕННОМУ статусу: Default checked → «проверяется в каждом проходе»; OnPush checked и это карточка-источник клика → «помечена dirty кликом»; OnPush checked и менялся input → «помечена dirty сигналом»; OnPush skipped → «инпуты не менялись, не помечена dirty». Сам факт checked/skipped всегда реальный, наименование причины — типовая формулировка правила.
- Степпер-реплей. После закрытия окна шаги копируются ОДИН раз в
replaySteps, и<sb-stepper>проигрывает запись; дерево подсвечивается по снимку ТЕКУЩЕГО шага (replayStatusinput каждой карточки:'checked' | 'skipped' | null). - Карточка-предсказание. Вопрос жёстко привязан к сценарию «клик внутри „OnPush — D“»:
actualIndexвычисляется из РЕАЛЬНО записанного прохода именно этого сценария (другие триггеры дерево обновляют, но карточку не трогают — вопрос конкретный, не общий). - Стили карточек дерева — собственные (inline
stylesв дочерних компонентах, не в этом.less): Angular ViewEncapsulation.Emulated scoпит стили ПО КОМПОНЕНТУ, создавшему элемент, а не по родителю — стиль из__name__.less, нацеленный на.cd-nodeвнутри<cd-node-default>/<cd-node-onpush>, не совпал бы с их разметкой. Поэтому уCdNodeDefault/CdNodeOnPushесть свойstylesблок; в__name__.lessостались только shell/сетка/легенда и.badgeдля заголовков поддеревьев (эти теги — в родительском шаблоне).
Что в скаффолде
| Файл | Содержимое |
|---|---|
__name__.ts |
Главный компонент {{className}} (OnPush) + CdNodeDefault (Default) и CdNodeOnPush (OnPush) в том же файле — каждый со своим recordCheck()/checked output/ownClick output/resetOwn(). |
__name__.html |
Два поддерева (@for по defaultNodes/onpushNodes), 4 кнопки-триггера + Reset, анти-паттерн «мутация без signal», карточка-предсказание, дерево с replayStatus, реплей-секция со степпером, легенда. |
__name__.less |
Shell/сетка/легенда/секции; карточки узлов стилизуют себя сами (см. выше). |
__name__.routes.ts |
Ленивый маршрут path: ''. |
Попробуй (эксперименты)
- В карточке «Предскажи результат» выбери вариант, затем нажми кнопку «Клик внутри карточки» у OnPush — D — проверь себя. Правильный ответ: «Default — A», «Default — B» и «OnPush — D» (Default проверяются всегда; «OnPush — C» пропущена — её input не менялся и она не была источником события).
- Прощёлкай реплей того же прохода: дерево подсвечивает карточки по очереди — сначала checked (зелёная рамка), затем skipped (серая, тусклая) с причиной в тексте шага.
- Нажми «Клик снаружи (root-событие)» — в реплее увидишь: обе Default-карточки checked, обе OnPush skipped (их inputs не менялись, событие произошло не внутри них).
- Нажми «Изменить sharedInput» — теперь ОБЕ OnPush-карточки становятся checked: изменился их input, а не «клик».
- Нажми «setTimeout → signal (500 мс)» и посмотри в консоли/легенде: сам таймер CD не запускает — CD планирует именно
parentCounter.update()внутри колбэка, через 500 мс после клика. - Нажми «Мутировать поле напрямую (без signal)» несколько раз — счётчик «внутреннее значение» на экране НЕ изменится. Затем нажми любой другой триггер — значение «догонит» реальное на экране, потому что теперь прошёл настоящий CD-проход.
- Измени стратегию
CdNodeDefaultнаOnPushпрямо в коде и запусти «Изменить sharedInput» ещё раз — обе бывшие Default-карточки станут вести себя как OnPush.
Ловушки / на что смотреть
recordCheck()не пишет в signal синхронно. Запись в signal во время выполнения шаблона запланировала бы новый CD-проход прямо из текущего (риск бесконечного цикла, NG0100).recordCheck()поэтому лишь синхронно эмититcheckedoutput (обычный вызов функции, не запись в signal) — это безопасно прямо во время CD. Родитель тоже не пишет в signal из обработчика(checked)— копит id в обычномSet(passChecked), и только после закрытия окна прохода (вsetTimeout(0), т.е. уже не во время CD) переносит агрегат в signalnodeCounts, который раздаётся карточкам черезinput().- «checked N times» считает родитель, не карточка — issue из ревью, исправлено. Раньше каждая карточка сама вела свой счётчик через буфер
pendingChecks+ эвристикуselfInflicted, пытаясь отличить «эхо от своего же флаша» от настоящей проверки. Эвристика ломалась в двух местах:resetOwn()обнулялselfInflictedДО своих же записей в signal (стрейт после Reset давал ложный +1), и собственныйqueueMicrotaskродителя вfinalizePass()(пишетreplaySteps/stepPositionПОСЛЕ закрытияpassOpen) доходил реальным проходом до Default-карточек и давал фантомный +1, которого карточка не могла отличить от настоящего check. Карточка структурно не может отличить «эхо от себя» от «эхо от бухгалтерии родителя» — чинить эвристику дальше означало бы копить всё новые частные случаи. Вместо этого счётчик убрали из карточек совсем:count/flashing— чистые presentation-inputs, а авторитетное число — то же самое множествоpassChecked, что уже использовалось для реплея (см.docs/progress/task-stage3-change-detection.mdза подробным разбором и живой проверкой на dev-сервере). - OnPush ≠ «никогда не проверяется». OnPush пропускает компонент, только если Angular не видит «грязного» маркера. Маркер ставится либо изменением
input(), либо событием/сигналом ВНУТРИ самого компонента — оба случая в демке различимы по тексту причины в реплее. - Хуки
ngDoCheck/ngAfterViewCheckedне годятся как счётчик проверок — они срабатывают у ребёнка каждый раз, когда проверяется его родитель, даже если OnPush-ребёнок был пропущен. Честный сигнал «шаблон реально выполнен» — только вызов из самого шаблона (recordCheck()). - Приложение zoneless (без zone.js, без
provideZoneChangeDetection): в легенде и коде нет упоминаний зон — только signals, события и явныйmarkForCheck().setTimeoutНЕ запускает CD сам по себе; запускает его именно запись в signal внутри колбэка. - Причина «skipped» — не измерение, а типовая формулировка, применённая к измеренному факту (карточка НЕ вызвала
recordCheck()в этом окне). Сам факт checked/skipped всегда берётся из реально записанного прохода, а не хардкодится.