Editing NgRx Signal Store State with Angular Signal Forms
A create form starts from nothing, so a local signal is the right model for its draft. An edit form starts from a task that already lives in the NgRx Signal Store, and the store is the one owner of its state. So while the user is typing, who owns the draft?
In the previous post we moved the create form of our task board to Angular Signal Forms and made it submit through a mutation into the store, which is event-driven. The board shows household tasks in 3 columns, To Do, In Progress and Done, served by a mock service. In this post we take its second form, the one that edits a task in place. We look at the 2 ways to own its draft, and why I let the store keep it.
Handing Store State to form()
The store keeps the task being edited in taskEdit, which is null while no edit is open, so the obvious first try is to hand that straight to form():
form(this.store.taskEdit);
// ❌ Argument of type 'DeepSignal<TaskEdit> | Signal<null>' is not assignable
// to parameter of type 'WritableSignal<null>'.
form() needs a writable signal, since typing in a field writes into the model. A store's state is read-only from outside, and that is deliberate: only the store changes its state. So we need something writable that sits between the form and the store.
Two Ways to Own the Draft
The writable piece in between is linkedSignal. It works like a computed: its value comes from another signal and follows it whenever that signal changes. Unlike a computed, it is also writable, and a value we set holds until the source changes again. So it starts from the store and still lets the form type into it, and there are 2 ways to set it up.
In ngrx/platform#5053, Rainer Hahnekamp describes both: a local copy that is written back on submit, and, since Angular 22.1, a linkedSignal that writes straight to the store. Our board uses the second, but the local copy is the simpler one, so we start there.
A Local Copy, Written Back on Submit
Here the form works on its own copy, and the store only hears about it on save. Since our board doesn't do this, the following snippet isn't in its repo. It only shows the shape:
readonly edit = linkedSignal(() => this.store.taskEdit() ?? NO_EDIT);
readonly editForm = form(this.edit, path => {
apply(path.title, taskTitleSchema);
});
The form types into the copy freely, and the copy starts again from the store whenever taskEdit changes. Submit then calls a store method with the copy.
NO_EDIT is an empty edit that stands in while taskEdit is null. The board only shows the editor while an edit is open, so it's there for the types, not for the screen. taskTitleSchema holds the title rules from the previous post, shared with the create form.
That's all a local copy needs, and it's enough as long as nothing else needs the draft while the user is typing.
The Store Owns the Draft
When something else does need the draft while the user types, the store has to hold it. linkedSignal has a set option for that, which decides what a write to it does. Our edit form uses it to send every write to the store instead of keeping a copy:
readonly edit = linkedSignal(() => this.store.taskEdit() ?? NO_EDIT, {
set: edit => this.store.updateTaskEdit(edit),
});
readonly editForm = form(this.edit, path => {
apply(path.title, taskTitleSchema);
});
Every write the form makes, each keystroke and every reset(value), goes through set to a store method instead of into a local copy. The store updates taskEdit, the linkedSignal recomputes from it, and the form shows what the store holds.
> 💡 The store owns the draft, and the form is a view over it, which is the same idea as in the create form, except that the store holds the draft instead of a local signal in the component.
Methods or Events for a Draft
Since the store now owns the draft, it also needs a way to open, change and close it. A card's Edit button opens it with startEditing, every write from the form goes through set to updateTaskEdit, and the editor's Cancel button closes it with stopEditing. So the store side is 3 small methods:
withMethods(store => ({
startEditing(taskId: string) {
const task = store.taskEntityMap()[taskId];
if (task) {
patchState(store, {
taskEdit: { id: task.id, title: task.title, description: task.description ?? '' },
});
}
},
updateTaskEdit(taskEdit: TaskEdit) {
patchState(store, { taskEdit });
},
stopEditing() {
patchState(store, { taskEdit: null });
},
})),
These are methods, not events, on purpose. Our store is event-driven, but updateTaskEdit fires on every keystroke and changes nothing but the draft.
An event per keystroke would add an entry to the event log for every key the user presses, so the events the rest of the board relies on would get lost among them.
startEditing and stopEditing could have been events, since the rule from the previous post is that anything nothing awaits can be an event. They stayed methods so the 3 live together as one small API for the draft, and because opening an editor is UI state, not a change to a task.
If your event log is also your audit trail, make them events.
Saving Without Overwriting a Status Move
Once the user is done typing, saving goes through a mutation from @ngrx-toolkit/core, with the same shape as the create form's, so the component can await the outcome:
saveTaskEdit: rxMutation<TaskEdit, Task>({
operation: ({ id, title, description }: TaskEdit) =>
store._taskService.updateTask(id, { title, description }),
onSuccess: task =>
store._dispatcher.dispatch(taskApiEvents.taskUpdatedSuccess(task)),
onError: error =>
store._dispatcher.dispatch(
taskApiEvents.taskUpdatedFailure(errorMessage(error))
),
}),
The component awaits it inside submit(), and when the server rejects a save, its message shows under the title input, exactly like a rejected create in the previous post. The success event is where the edit ends, shown here without its logging:
on(taskApiEvents.taskUpdatedSuccess, event => [
updateEntity(
{
id: event.payload.id,
changes: { title: event.payload.title, description: event.payload.description },
},
{ collection: 'task' }
),
{ taskEdit: null },
]),
And here is what that reducer does:
1. It applies only the fields the form edits. Taking the whole saved task would also overwrite a status move that is still on its way to the server, since moving a card is an optimistic update.
2. Setting taskEdit to null closes the editor, and the board removes the edit component.
Tests, One in Each Direction
The edit form gets the same test as the create form for a save the server rejects. These 2 tests cover the linkedSignal:
it('writes what is typed straight into the store', () => {
component.editForm.title().value.set('Water the herbs');
expect(store.taskEdit()?.title).toBe('Water the herbs');
});
it('follows the store when the draft changes there', () => {
store.updateTaskEdit({ id: '1', title: 'Changed elsewhere', description: '' });
expect(component.editForm.title().value()).toBe('Changed elsewhere');
});
The first checks that the form writes into the store, the second that the form follows the store, so together they prove there is one draft, and it lives in the store.
Without the set option, the form falls back to a local copy and the first test fails, which is the difference between the 2 approaches.
When to Let the Store Own the Draft
Which way a form should go depends on who else needs the draft.
Keep a local copy when:
- Only the form needs the draft, and the store only cares about the saved result
- You want cancelling to be simple: drop the copy, and the store never sees the edits
Let the store own it when:
- Something else reacts to the draft while the user types, like a preview, a dirty flag on the board or autosave
- The draft has to survive the form component being destroyed, for example on a route change (not in this board)
- You already keep editor state such as taskEdit in the store
> 💡 Both are a few lines with linkedSignal, so this is a per-form choice. The set option is what turns a local copy into a store-owned draft, and the test that writes into the store is what keeps it that way.
Resources
- Code for this article: View on GitHub (v4.1.0-signal-forms-custom-control) (the same tag as the previous post, which already includes the edit form)
- Signal Forms documentation
- ngrx/platform#5053: Using Signal Store and Signal Forms
- angular/angular#68708: the custom set option on linkedSignal
- Previous article: Angular Signal Forms with an Event-Driven NgRx Signal Store (the create form, the custom control and the mutation)
- Event-Driven State Management with NgRx Signal Store (the events plugin the board runs on)
Where does the draft live in your forms? If you've kept edit state in a Signal Store some other way, I'd like to hear how.
Inside Angular’s Change Detection Zones: How UI Updates Are Scheduled and Batched
You’ve just fixed a bug where your Angular component’s UI didn’t update right after an HTTP request. You refresh, hoping the input reflects the latest data immediately. But nope, the UI lags, or requires a second click, or only updates after you interact again. What gives?
I’ve been there. It feels like Angular’s change detection is on its own schedule, ignoring your updates. Tracking down the root cause led me straight into Angular’s NgZone and how it intercepts asynchronous operations to schedule UI updates.
The Familiar Developer Frustration: UI Doesn’t Update When You Think It Should
Imagine this: you call a service that fetches data with HttpClient. You subscribe, set a component property in the callback, and expect Angular to refresh the template.
this.http.get('/api/data').subscribe(data => {
this.user = data;
// Expect UI to update immediately
});
But sometimes the UI stays stale. Or, if you’re using third-party libraries or custom async code, change detection doesn’t run automatically. You might resort to triggering it manually with ChangeDetectorRef.detectChanges() or NgZone.run(). That’s a red flag something subtle is happening under the hood.
What Is NgZone and Why Does Angular Use It?
Angular wraps your app’s execution inside an NgZone, an Angular-specific zone based on the zone.js library. Zones monkey-patch async browser APIs like setTimeout, promises, events, and XHR. This lets Angular know when async work starts and finishes.
Why does it matter? Because Angular’s change detection is not running constantly or on every microtask. Instead, Angular waits for these async activities to complete and then triggers change detection once per turn of the event loop. This batching avoids excessive UI work.
In short, NgZone is Angular’s way to track async boundaries and schedule UI updates efficiently.
How NgZone Intercepts Asynchronous Operations
Behind the scenes, zone.js patches these APIs:
- Timers: setTimeout, setInterval
- Promises: .then() callbacks
- DOM events: addEventListener
- XHR and fetch
Each async callback runs inside the NgZone. When an async task starts, NgZone increments an internal counter. When it completes, it decrements it. When the counter hits zero, NgZone knows the async turn is over and calls Angular’s change detection.
This is why Angular’s change detection doesn’t run immediately at every microtask or callback, but batches updates until the async “turn” is done.
What Happens When You Run Code Outside NgZone?
Sometimes, to optimize performance, you or a library might run code outside Angular’s zone using NgZone.runOutsideAngular(). This means Angular doesn’t intercept those async callbacks.
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
// Angular change detection won't run after this timer
this.user = newUser;
}, 1000);
});
If you update component state inside such callbacks without re-entering Angular’s zone, your UI won’t update automatically. That’s a common source of confusion.
The fix: wrap updates inside NgZone.run(), or trigger change detection manually.
The Tradeoff: Performance vs Predictability
NgZone’s interception is powerful but comes at a cost. Monkey-patching async APIs adds overhead, which can affect performance in large apps or complex async flows.
Because Angular batches change detection after all async tasks finish, UI updates might feel delayed if you have chained or long-running async operations.
Some experiments and libraries aim to avoid zones entirely (like Angular Signals or manual change detection strategies) for more control and performance, but zones remain the default for good developer ergonomics.
Examples: When Change Detection Runs
Example 1: Simple Promise
Promise.resolve().then(() => {
this.count++;
// Angular will detect changes after this microtask finishes
});
Angular patches promise .then(), so after the microtask queue empties, change detection runs.
Example 2: setTimeout
setTimeout(() => {
this.count++;
// Angular detects changes after timer callback
}, 100);
NgZone tracks timers similarly, triggering change detection once the callback completes.
Example 3: Event Listener
document.getElementById('btn').addEventListener('click', () => {
this.count++;
// UI updates because event listener runs inside NgZone
});
Events patched by zone.js trigger change detection after handlers.
Example 4: Outside Angular Zone
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.count++;
// UI stays stale because this runs outside Angular zone
}, 100);
});
No automatic change detection unless you re-enter Angular zone or detect changes manually.
Debugging Unexpected UI Update Timings
If your UI doesn’t update as expected:
- Check whether your async code runs inside NgZone.
- Use console.log(NgZone.isInAngularZone()) inside callbacks to verify.
- Try forcing change detection temporarily with ChangeDetectorRef.detectChanges() to isolate the problem.
- Beware of third-party libraries that operate outside Angular zones.
When Change Detection Might Feel Too Aggressive
Because NgZone triggers detection after every async turn, sometimes Angular runs more often than you want, especially with frequent timers or event-heavy components.
You can optimize with:
- ChangeDetectionStrategy.OnPush to reduce detection scope
- Running complex code outside Angular zones if UI updates aren’t needed every time
- Using signals or other reactive primitives for fine-grained updates
Wrapping Up
NgZone is Angular’s secret sauce for knowing when async work finishes so it can batch and schedule change detection. It patches your async browser APIs, tracks when tasks start and finish, and triggers UI updates accordingly.
This makes Angular apps feel reactive without you having to call detectChanges() everywhere. But it can also surprise you when third-party async code runs outside Angular’s zone, or when UI updates lag behind your expectations.
Understanding NgZone’s role helps you debug those subtle UI update glitches and write more predictable, performant Angular apps.
---
Helpful learning resources
- MDN Web Docs
- web.dev performance guidance
- OpenAI developer resources
Originally published at Under The Hood.
Get the next deep dive in your inbox: subscribe to Under The Hood.
Angular 22+ projects are having an issue in Stackblitz currently, but while the larger issue is being fixed, try this workaround that Material added to their own starter: components#34030. Larger issue: angular-cli#34524. The workaround worked for my own projects.
3 likes
My pull Angular CLI pull request has been merged! 🎉 After three review rounds with Alan Agius, prerender.format is now part of Angular itself. I'm a little proud: a community request from 2024 that never collected enough votes is now an official option of the CLI.
angular-cli#34180
6 likes · 2 reposts
С Karma на Vitest: как мы обновляли unit-тесты Angular и починили Allure
Привет! На связи Дмитрий Чепайкин, старший Angular-разработчик в команде Klecks — платформы для разметки данных AI Центра Т-Банка.
Долгое время unit-тесты фронтенда у нас крутились на Karma + Jasmine. Схема работала, пайплайн зеленый, переписывать «ради моды» не хотелось. Но в какой-то момент Karma официально признали deprecated, а корректная отгрузка отчетов в Allure перестала быть чем-то, на что можно положиться. Расскажу, как мы выбирали замену, почему сначала смотрели на Jest, а в итоге приняли Vitest — и как устроен итоговый сетап.
Триггеры миграции
Во фронтенде Klecks три тестовых проекта: customer, operator и shared. Кодовая база включает сотни *.spec.ts, использует Angular TestBed, моки сервисов, фейковые таймеры и кастомные хелперы вроде UnhandledCatcher. При этом интеграционные сценарии уже давно выполнялись на Playwright, а unit-тестирование продолжало опираться на классический Angular-стек: Karma через builder @angular-devkit/build-angular:karma, Jasmine в качестве тестового API и Chrome Headless для запуска в CI. Покрытие кода собиралось через karma-coverage, а результаты unit-тестов пытались выгружать в Allure вместе с e2e-тестами.
Формально все работало. Но стоимость поддержки стека росла, а на Karma уже не решалась надежно одна из задач качества — нормальная иерархия unit-тестов в Allure. Поэтому стоял вопрос не «Нужен ли нам новый раннер?», а «Какой раннер позволит уйти с deprecated-инструмента и при этом не развалить существующие тесты и отчетность?».
Зачем трогать то, что работает
Karma справлялась со своими задачами годами. Руки до миграции не доходили не потому, что стек был идеален, а потому, что он был «достаточно хорош». Локально ng test открывал браузер, в CI Chrome Headless прогонял suites. Менять инфраструктуру при живом продукте — всегда риск.
Мы собрали список проблем.
Deprecated и тупиковая поддержка. Karma больше не развивается как основной современный test runner, поэтому дальнейшая интеграция с актуальным инструментарием требует все больше поддержки legacy-слоя. Оставаться на Karma — значит тащить устаревающий слой между CLI и тестами, который рано или поздно упрется в несовместимость с новой версией Angular/Webpack/Vite.
Allure для unit-тестов. Для e2e у нас уже был рабочий контур Playwright → Allure. Для unit на Karma отчеты не собирались, так как не было стабильного плагина, полноты и стабильности в выгрузке артефактов тестов. В CI это превращалось в «тесты зеленые, а в Allure unit-слой неполноценный». Именно эта боль стала триггером: отчетность — часть процесса, а не опциональный бантик.
Тяжелый браузерный рантайм в CI. Karma поднимает реальный Chrome. Для части DOM-тестов это плюс, но для сотен unit-спеков — лишняя тяжесть образа, флаки по браузеру и более дорогой пайплайн. Cypress-образ ради unit-прогона сам по себе сигнал, что инфраструктура разъехалась с задачей.
Jasmine-специфика. API привычный, но экосистема вокруг моков, таймеров и матчеров все сильнее расходится с тем, что используют Jest/Vitest-мир и современные Allure-репортеры. Любая новая интеграция требовала прокладок под Karma и Jasmine, а не готового first-class-плагина.
Основными целями миграции стали отказ от устаревшего Karma, обеспечение корректной выгрузки результатов unit-тестов в Allure и сохранение существующей бизнес-логики тестов. Изменения должны были затронуть только инфраструктуру тестирования и API тестового раннера.
Рассматривали: Jest vs Vitest
Мы не прыгали сразу в финальный выбор. Сначала на ветке test_jest собрали полноценный прототип на Jest: jest-preset-angular, отдельные конфиги создали на customer / operator / shared, allure-jest, добавили адаптеры под Jasmine-совместимые хелперы (expectAsync, toBeTrue / toBeFalse, глобальный spyOn). Это был честный spike: можно ли перенести сотни спеков и закрыть Allure.
Эксперимент с Jest показал, что можно закрыть основные боли:
нативный Node + jsdom без Chrome;
официальный путь через jest-preset-angular;
рабочий allure-jest с иерархией лейблов;
параллельный прогон проектов.
Но именно для нашего Angular-монорепо появились минусы:
отдельный контур мимо Angular builder — скрипты jest --config ... вместо ng test;
свой зоопарк конфигов (create-jest-config, tsconfig.jest.json, project configs);
необходимость compatibility-слоев под старый Jasmine API;
трансформ ESM/CJS-зависимостей (transformIgnorePatterns на длинный список пакетов Taiga/Angular);
два мира инструментов: CLI/сборка на одном стеке, тесты — на другом.
Jest как технология нас не провалил. Он показал, что миграция с Karma возможна и что Allure на Node-раннере реально оживает. Но как целевой стек для Angular 21 он выглядел компромиссом, а не финальной точкой.
В итоге мы выбрали Vitest. После Jest-spike мы собрали решение на Vitest — коммит с принятым направлением: миграция builders, конфигов и спеков. Выбор опирался на набор практических критериев.
Интеграция Vitest через Analog позволила сохранить запуск через Angular CLI и стала подходящим вариантом для нашего текущего workspace. Vitest подключается через @analogjs/vitest-angular:test. Для разработчика вход тот же:
ng test customer
ng test operator --configuration=cisourcecraft.dev
В angular.json вместо karma-builder:
"builder": "@analogjs/vitest-angular:test",
"options": {
"configFile": "vitest.config.mts"
}sourcecraft.dev
Мы остаемся внутри Angular CLI, а не уходим в параллельную систему запуска.
Миграция на Vitest для нас закрыла ряд вопросов.
Vite-based pipeline вместо отдельного Jest transform layer. Vitest работает на Vite: быстрее старт, нативная работа с ESM, проще inline зависимостей Angular/Taiga через server.deps.inline. Там, где в Jest копился transformIgnorePatterns, в Vitest мы описываем resolve/alias и список пакетов для инлайна — и это лучше ложится на текущий фронтенд-стек.
Прямая интеграция с Allure через allure-vitest. allure-vitest/reporter пишет результаты в allure-results/unit/<project>. Плюс общий setup автоматически проставляет иерархию: parentSuite → suite → layer → epic → subEpic.
То есть unit-слой наконец встает рядом с интеграционными отчетами по тем же правилам качества.
Меньше «клея», ближе к целевому API. Vitest из коробки дает vi.fn, vi.spyOn, fake timers, globals. Jasmine → Vitest — механическая замена в спеках, без отдельного compatibility-runtime, который пришлось бы сопровождать на Jest.
CI без браузера и с контролем воркеров. Unit-джоба переехала на Node-образ. Воркеры ограничены явно: в CI VITEST_MAX_WORKERS=2, потому что Angular + jsdom + coverage прожорливы по RAM, а K8s часто отдает чужой CPU count. Это осознанная настройка под нашу инфраструктуру, а не дефолт «съешь все ядра».
Вывод: Jest доказал миграцию, Vitest лучше сел как целевой раннер для Angular-приложений Klecks.
Миграция самих тестов
Большая часть работы — не переизобрести тесты, а перевести API. Вот типичные замены.
Было (Jasmine)
Стало (Vitest)
jasmine.createSpy()
vi.fn()
spyOn(obj, 'method')
vi.spyOn(obj, 'method')
jasmine.createSpyObj(...)
ручной stub / vi.fn на методах
and.returnValue(x)
mockReturnValue(x)
and.callFake(fn)
mockImplementation(fn)
calls.reset()
mockClear() / mockReset()
jasmine.clock().install()
vi.useFakeTimers()
jasmine.clock().tick(n)
vi.advanceTimersByTime(n)
jasmine.objectContaining
expect.objectContaining
done()-колбэки
async/await + Promise
jasmine.Spy в типах
Mock из vitest
TestBed, ComponentFixture, провайдеры и структура describe/it почти не менялись.
Важно, что миграция раннера не должна была стать рефакторингом продуктового кода. Мы отдельно подтянули инфраструктурные хелперы. Например, UnhandledCatcher научили ловить и window.onerror, и rxjs.config.onUnhandledError. В jsdom/Vitest необработанные ошибки Observable ведут себя иначе, чем в Karma/Chrome. Такие места всплывают только на реальном прогоне, поэтому spike на Jest тоже был полезен: часть проблем API мы увидели еще до финального выбора.
Allure: как закрыли исходную боль
Что мы сделали по шагам:
Репортер пишет в allure-results/unit/<project>.
Автолейблы строят иерархию из пути файла и имени теста.
Epic выбирается по расположению: customer / operator / shared.
Локально можно собрать и открыть отчет теми же npm-скриптами, что и для integration
Параллелизм: Chrome Headless / Karma → Vitest threads, maxWorkers=2
В итоге unit-слой перестал быть «второй реальностью» относительно Playwright: и там и там Allure понимает слой, suite и продуктовый epic.
Что принесла миграция на vitest.
Было
Стало
Раннер
Karma + Jasmine + Chrome
Vitest + jsdom (+ Analog builder)
Статус стека
deprecated / legacy path
Актуальный Angular-friendly-путь
Запуск
ng test через karma-builder
ng test через @analogjs/vitest-angular:test
Allure unit
Ненадежная/неполная выгрузка
allure-vitest + автолейблы
CI-образ
Тяжелый browser-based
Node 22
Параллелизм
Браузерный single-run контур
threads + явный лимит воркеров
Скорость джобы в CI
~3 минуты 40 секунд
~2 минуты 20 секунд
Плюсы, которые мы получили:
ушли с deprecated Karma;
сохранили привычный DX через ng test;
unit-отчеты в Allure стали частью нормального процесса;
CI стал проще по зависимостям (без Chrome ради unit);
спеки мигрируют механически, без переписывания сценариев;
ускорили джобу unit тестов в CI на ~36,4%.
Но есть и компромиссы. Они для нас— разумная цена за поддерживаемый стек и рабочую отчетность. Компромиссы, о которых стоит знать:
jsdom ≠ полный браузер: редкие DOM/CSS edge-cases могут вести себя иначе;
в CI нельзя бездумно крутить maxWorkers: coverage + Angular TestBed легко упираются в память;
часть хелперов (UnhandledCatcher, fake timers) потребовала точечной адаптации под новый рантайм.
Нативный Angular Vitest (@angular/build:unit-test) мы сознательно не брали, потому что для нашего кейса он слишком закрытый. Официальный builder заточен под zero-config: Angular сам поднимает Vitest, а кастомный vitest.config либо ограничен, либо поддерживается частично.
Команда Angular прямо говорила: если нужен полноценный vitest.config.ts (плагины, репортеры, тонкая настройка), берите Analog.
Для нас такой уровень контроля был принципиален. Требовалось интегрировать allure-vitest/reporter с собственной иерархией лейблов, создать единый factory-конфиг для customer, operator и shared с общими алиасами, путями Less и директориями покрытия. Кроме того, необходимо было управлять количеством воркеров через VITEST_MAX_WORKERS, чтобы учитывать ограничения памяти в K8s при запуске Angular-тестов с jsdom и coverage, а также гибко настраивать параметры рантайма, включая isolate, inline deps и setupFiles.
Analog (@analogjs/vitest-angular) дает полный контроль «из коробки» и при этом оставляет ng test. Jest-spike уже показал, что «просто зеленых тестов» недостаточно — нужна управляемая инфраструктура и отчеты.
Native имеет смысл для зеленого поля или простых приложений. Для нашего монорепо с Allure и жестким CI Analog оказался практичнее. Позже, когда @angular/build:unit-test догонит по конфигурируемости, можно будет пересмотреть — но на момент решения Analog закрывал требования лучше.
Заключение
Мы не меняли раннер, потому что «все переходят на Vitest». Триггером стала связка из двух фактов: Karma deprecated и Allure для unit на текущем стеке больше не закрывал требования качества.
Jest на ветке test_jest подтвердил, что Node-раннер + Allure — рабочий путь. Vitest выбрали как целевое решение, потому что он лучше стыкуется с Angular CLI через Analog, естественнее живет с Vite/ESM и дает прямую интеграцию с Allure через allure-vitest без отдельной параллельной инфраструктуры запуска.
Итог миграции:
один shared Vitest-конфиг на три проекта;
builder в angular.json без Karma;
автолейблы Allure в @klecks/testing;
механический перевод Jasmine API → Vitest в спеках;
CI на Node с ограниченными воркерами;
unit-тесты в CI проходят на ~36,4% быстрее;
Если у вас похожий Angular-монолит на Karma и уже болит Allure/поддержка, имеет смысл сначала сделать короткий spike на Jest или Vitest именно с выгрузкой отчетов, а не только с «тесты зеленые локально». У нас зеленость никогда не была главной проблемой. Главным было сделать так, чтобы unit-слой снова был наблюдаемым и поддерживаемым.
github.com/angular/components/pulls
Shuaib Hasan Akib done a lot of work in the Angular/Material docs to modernize their source code, among a lot of other things. If you look at open/closed PRs with him tagged in either repo (merged Material issues in this link) you will see all sorts of good refactors.
Shoutout to our community for an amazing contribution from Angelo Parziale.
PR #70527 introduces strictUnclaimedEventNames, a new opt-in compiler option to catch misspelled output bindings by checking directive outputs and native events.
Explore the PR: angular#70527 🛠
5 likes · 2 reposts
@mfpears Hey Mike, I know there are angular-developer skills (angular/skills), but I wonder if you had any that help Agents write declarative code. I feel like the general overview of signals or resource does not quite cut it
2 likes · 31 views
週刊プログラミングニュース|Deno・Python・OSSと開発ツール【2026-W42/10月9〜15日】
週刊プログラミングニュース|2026年10月9日〜15日 | 2026-W42
最初にDeno、Python、Bun、GitHub Actions、.NET、シークレット保護。その後、OSS・フレームワーク、ライブラリー・データベース、IDE・開発プラットフォームを章ごとに整理します。
対象期間:10月9日00:00〜15日23:59 JST。時刻を確認できた発表はJSTへ換算し、日付のみの資料は公式表記日を採用しています。境界時刻が未確認の候補は確定分と区別しました。
正式版、プレビュー、今後の予定を区別しています。Dockerは修正済み問題の開示、Nodeの10月21日予定分は未提供の修正予告です。
映像は公式情報に基づく解説図で、実機デモではありません。性能値は発表元の測定で、独自の実測ではありません。
日本語音声・日英字幕。
チャプター
00:00 週刊プログラミングニュース
00:28 DenoチームがCloudflareへ
01:50 Python 3.15が正式公開
03:18 Bun 1.4.3に型チェッカー
04:30 Actionsにワークフローロック
05:38 .NETは安定版更新とRC2
07:00 GitHubのシークレット検出拡大
07:59 Next.jsのセキュリティ更新
09:28 Express・Fastify・Micronaut
10:57 フロントエンドの安定版とRC
12:16 Astro・Rolldown・Laravel
13:21 Hydrogenがライブラリーへ
14:14 Pydantic・NumPy・FastAPI
15:59 PgBouncerとpsqlODBC
17:25 SQLite 3.54.0
18:38 Ruby 4.1のプレビュー
19:39 Node.js更新と次のセキュリティ修正
20:51 VS CodeとVisual Studio
22:07 Cursor Originのアプリ連携
22:54 GitLabの緊急修正
23:57 Docker Hubの修正済み問題の開示
25:04 Lambdaの新しい実行モデルと接続維持
26:27 今週の確認リスト
公式出典
deno.com
blog.python.org
bun.com
github.blog
devblogs.microsoft.com
github.blog
devblogs.microsoft.com
github.com/dotnet/core/blob/main/release-notes/10…
nextjs.org
github.com/expressjs/express/releases/tag/v5.3.0
github.com/fastify/fastify/releases
github.com/fastify/fastify/security/advisories/GH…
micronaut.io
micronaut.io
github.com/angular/angular/releases/tag/v22.2.3
github.com/vuejs/core/releases
github.com/vuejs/core/blob/main/CHANGELOG.md
github.com/vuejs/core/blob/minor/CHANGELOG.md
github.com/nuxt/nuxt/releases/tag/v4.6.1
github.com/sveltejs/kit/releases/tag/@sveltejs%2F…
github.com/withastro/astro/releases/tag/astro@7.4…
github.com/rolldown/rolldown/releases/tag/v1.2.14
github.com/laravel/framework/releases/tag/v13.36.0
github.com/laravel/framework/releases
shopify.dev
github.com/pydantic/pydantic/releases/tag/v2.14.0
pydantic.dev
github.com/pydantic/pydantic/releases
github.com/numpy/numpy/releases/tag/v2.5.4
github.com/fastapi/fastapi/releases/tag/0.143.1
github.com/fastapi/fastapi/releases/tag/0.143.2
pgbouncer.org
github.com/pgbouncer/pgbouncer/releases/tag/pgbou…
postgresql.org
sqlite.org
ruby-lang.org
nodejs.org
nodejs.org
nodejs.org
openwall.com
code.visualstudio.com
learn.microsoft.com
cursor.com
docs.gitlab.com
docker.com
aws.amazon.com
developers.cloudflare.com
40 views
@Armandotrue ‘DeferredContent’ directive from Angular Aria does this and it’s beautiful. I use the exact same directive for my custom components as well.
github.com/angular/components/blob/main/src/aria/…
3 likes · 94 views
Mentions
Editing NgRx Signal Store State with Angular Signal Forms A create form starts from nothing, so a local signal is the right model for its draft. An edit form starts from a task that already lives in the NgRx Signal Store, and the store is the one owner of its state. So while the user is typing, who owns the draft? In the previous post we moved the create form of our task board to Angular Signal Forms and made it submit through a mutation into the store, which is event-driven. The board shows household tasks in 3 columns, To Do, In Progress and Done, served by a mock service. In this post we take its second form, the one that edits a task in place. We look at the 2 ways to own its draft, and why I let the store keep it. Handing Store State to form() The store keeps the task being edited in taskEdit, which is null while no edit is open, so the obvious first try is to hand that straight to form(): form(this.store.taskEdit); // ❌ Argument of type 'DeepSignal<TaskEdit> | Signal<null>' is not assignable // to parameter of type 'WritableSignal<null>'. form() needs a writable signal, since typing in a field writes into the model. A store's state is read-only from outside, and that is deliberate: only the store changes its state. So we need something writable that sits between the form and the store. Two Ways to Own the Draft The writable piece in between is linkedSignal. It works like a computed: its value comes from another signal and follows it whenever that signal changes. Unlike a computed, it is also writable, and a value we set holds until the source changes again. So it starts from the store and still lets the form type into it, and there are 2 ways to set it up. In ngrx/platform#5053, Rainer Hahnekamp describes both: a local copy that is written back on submit, and, since Angular 22.1, a linkedSignal that writes straight to the store. Our board uses the second, but the local copy is the simpler one, so we start there. A Local Copy, Written Back on Submit Here the form works on its own copy, and the store only hears about it on save. Since our board doesn't do this, the following snippet isn't in its repo. It only shows the shape: readonly edit = linkedSignal(() => this.store.taskEdit() ?? NO_EDIT); readonly editForm = form(this.edit, path => { apply(path.title, taskTitleSchema); }); The form types into the copy freely, and the copy starts again from the store whenever taskEdit changes. Submit then calls a store method with the copy. NO_EDIT is an empty edit that stands in while taskEdit is null. The board only shows the editor while an edit is open, so it's there for the types, not for the screen. taskTitleSchema holds the title rules from the previous post, shared with the create form. That's all a local copy needs, and it's enough as long as nothing else needs the draft while the user is typing. The Store Owns the Draft When something else does need the draft while the user types, the store has to hold it. linkedSignal has a set option for that, which decides what a write to it does. Our edit form uses it to send every write to the store instead of keeping a copy: readonly edit = linkedSignal(() => this.store.taskEdit() ?? NO_EDIT, { set: edit => this.store.updateTaskEdit(edit), }); readonly editForm = form(this.edit, path => { apply(path.title, taskTitleSchema); }); Every write the form makes, each keystroke and every reset(value), goes through set to a store method instead of into a local copy. The store updates taskEdit, the linkedSignal recomputes from it, and the form shows what the store holds. > 💡 The store owns the draft, and the form is a view over it, which is the same idea as in the create form, except that the store holds the draft instead of a local signal in the component. Methods or Events for a Draft Since the store now owns the draft, it also needs a way to open, change and close it. A card's Edit button opens it with startEditing, every write from the form goes through set to updateTaskEdit, and the editor's Cancel button closes it with stopEditing. So the store side is 3 small methods: withMethods(store => ({ startEditing(taskId: string) { const task = store.taskEntityMap()[taskId]; if (task) { patchState(store, { taskEdit: { id: task.id, title: task.title, description: task.description ?? '' }, }); } }, updateTaskEdit(taskEdit: TaskEdit) { patchState(store, { taskEdit }); }, stopEditing() { patchState(store, { taskEdit: null }); }, })), These are methods, not events, on purpose. Our store is event-driven, but updateTaskEdit fires on every keystroke and changes nothing but the draft. An event per keystroke would add an entry to the event log for every key the user presses, so the events the rest of the board relies on would get lost among them. startEditing and stopEditing could have been events, since the rule from the previous post is that anything nothing awaits can be an event. They stayed methods so the 3 live together as one small API for the draft, and because opening an editor is UI state, not a change to a task. If your event log is also your audit trail, make them events. Saving Without Overwriting a Status Move Once the user is done typing, saving goes through a mutation from @ngrx-toolkit/core, with the same shape as the create form's, so the component can await the outcome: saveTaskEdit: rxMutation<TaskEdit, Task>({ operation: ({ id, title, description }: TaskEdit) => store._taskService.updateTask(id, { title, description }), onSuccess: task => store._dispatcher.dispatch(taskApiEvents.taskUpdatedSuccess(task)), onError: error => store._dispatcher.dispatch( taskApiEvents.taskUpdatedFailure(errorMessage(error)) ), }), The component awaits it inside submit(), and when the server rejects a save, its message shows under the title input, exactly like a rejected create in the previous post. The success event is where the edit ends, shown here without its logging: on(taskApiEvents.taskUpdatedSuccess, event => [ updateEntity( { id: event.payload.id, changes: { title: event.payload.title, description: event.payload.description }, }, { collection: 'task' } ), { taskEdit: null }, ]), And here is what that reducer does: 1. It applies only the fields the form edits. Taking the whole saved task would also overwrite a status move that is still on its way to the server, since moving a card is an optimistic update. 2. Setting taskEdit to null closes the editor, and the board removes the edit component. Tests, One in Each Direction The edit form gets the same test as the create form for a save the server rejects. These 2 tests cover the linkedSignal: it('writes what is typed straight into the store', () => { component.editForm.title().value.set('Water the herbs'); expect(store.taskEdit()?.title).toBe('Water the herbs'); }); it('follows the store when the draft changes there', () => { store.updateTaskEdit({ id: '1', title: 'Changed elsewhere', description: '' }); expect(component.editForm.title().value()).toBe('Changed elsewhere'); }); The first checks that the form writes into the store, the second that the form follows the store, so together they prove there is one draft, and it lives in the store. Without the set option, the form falls back to a local copy and the first test fails, which is the difference between the 2 approaches. When to Let the Store Own the Draft Which way a form should go depends on who else needs the draft. Keep a local copy when: - Only the form needs the draft, and the store only cares about the saved result - You want cancelling to be simple: drop the copy, and the store never sees the edits Let the store own it when: - Something else reacts to the draft while the user types, like a preview, a dirty flag on the board or autosave - The draft has to survive the form component being destroyed, for example on a route change (not in this board) - You already keep editor state such as taskEdit in the store > 💡 Both are a few lines with linkedSignal, so this is a per-form choice. The set option is what turns a local copy into a store-owned draft, and the test that writes into the store is what keeps it that way. Resources - Code for this article: View on GitHub (v4.1.0-signal-forms-custom-control) (the same tag as the previous post, which already includes the edit form) - Signal Forms documentation - ngrx/platform#5053: Using Signal Store and Signal Forms - angular/angular#68708: the custom set option on linkedSignal - Previous article: Angular Signal Forms with an Event-Driven NgRx Signal Store (the create form, the custom control and the mutation) - Event-Driven State Management with NgRx Signal Store (the events plugin the board runs on) Where does the draft live in your forms? If you've kept edit state in a Signal Store some other way, I'd like to hear how.
Inside Angular’s Change Detection Zones: How UI Updates Are Scheduled and Batched You’ve just fixed a bug where your Angular component’s UI didn’t update right after an HTTP request. You refresh, hoping the input reflects the latest data immediately. But nope, the UI lags, or requires a second click, or only updates after you interact again. What gives? I’ve been there. It feels like Angular’s change detection is on its own schedule, ignoring your updates. Tracking down the root cause led me straight into Angular’s NgZone and how it intercepts asynchronous operations to schedule UI updates. The Familiar Developer Frustration: UI Doesn’t Update When You Think It Should Imagine this: you call a service that fetches data with HttpClient. You subscribe, set a component property in the callback, and expect Angular to refresh the template. this.http.get('/api/data').subscribe(data => { this.user = data; // Expect UI to update immediately }); But sometimes the UI stays stale. Or, if you’re using third-party libraries or custom async code, change detection doesn’t run automatically. You might resort to triggering it manually with ChangeDetectorRef.detectChanges() or NgZone.run(). That’s a red flag something subtle is happening under the hood. What Is NgZone and Why Does Angular Use It? Angular wraps your app’s execution inside an NgZone, an Angular-specific zone based on the zone.js library. Zones monkey-patch async browser APIs like setTimeout, promises, events, and XHR. This lets Angular know when async work starts and finishes. Why does it matter? Because Angular’s change detection is not running constantly or on every microtask. Instead, Angular waits for these async activities to complete and then triggers change detection once per turn of the event loop. This batching avoids excessive UI work. In short, NgZone is Angular’s way to track async boundaries and schedule UI updates efficiently. How NgZone Intercepts Asynchronous Operations Behind the scenes, zone.js patches these APIs: - Timers: setTimeout, setInterval - Promises: .then() callbacks - DOM events: addEventListener - XHR and fetch Each async callback runs inside the NgZone. When an async task starts, NgZone increments an internal counter. When it completes, it decrements it. When the counter hits zero, NgZone knows the async turn is over and calls Angular’s change detection. This is why Angular’s change detection doesn’t run immediately at every microtask or callback, but batches updates until the async “turn” is done. What Happens When You Run Code Outside NgZone? Sometimes, to optimize performance, you or a library might run code outside Angular’s zone using NgZone.runOutsideAngular(). This means Angular doesn’t intercept those async callbacks. this.ngZone.runOutsideAngular(() => { setTimeout(() => { // Angular change detection won't run after this timer this.user = newUser; }, 1000); }); If you update component state inside such callbacks without re-entering Angular’s zone, your UI won’t update automatically. That’s a common source of confusion. The fix: wrap updates inside NgZone.run(), or trigger change detection manually. The Tradeoff: Performance vs Predictability NgZone’s interception is powerful but comes at a cost. Monkey-patching async APIs adds overhead, which can affect performance in large apps or complex async flows. Because Angular batches change detection after all async tasks finish, UI updates might feel delayed if you have chained or long-running async operations. Some experiments and libraries aim to avoid zones entirely (like Angular Signals or manual change detection strategies) for more control and performance, but zones remain the default for good developer ergonomics. Examples: When Change Detection Runs Example 1: Simple Promise Promise.resolve().then(() => { this.count++; // Angular will detect changes after this microtask finishes }); Angular patches promise .then(), so after the microtask queue empties, change detection runs. Example 2: setTimeout setTimeout(() => { this.count++; // Angular detects changes after timer callback }, 100); NgZone tracks timers similarly, triggering change detection once the callback completes. Example 3: Event Listener document.getElementById('btn').addEventListener('click', () => { this.count++; // UI updates because event listener runs inside NgZone }); Events patched by zone.js trigger change detection after handlers. Example 4: Outside Angular Zone this.ngZone.runOutsideAngular(() => { setTimeout(() => { this.count++; // UI stays stale because this runs outside Angular zone }, 100); }); No automatic change detection unless you re-enter Angular zone or detect changes manually. Debugging Unexpected UI Update Timings If your UI doesn’t update as expected: - Check whether your async code runs inside NgZone. - Use console.log(NgZone.isInAngularZone()) inside callbacks to verify. - Try forcing change detection temporarily with ChangeDetectorRef.detectChanges() to isolate the problem. - Beware of third-party libraries that operate outside Angular zones. When Change Detection Might Feel Too Aggressive Because NgZone triggers detection after every async turn, sometimes Angular runs more often than you want, especially with frequent timers or event-heavy components. You can optimize with: - ChangeDetectionStrategy.OnPush to reduce detection scope - Running complex code outside Angular zones if UI updates aren’t needed every time - Using signals or other reactive primitives for fine-grained updates Wrapping Up NgZone is Angular’s secret sauce for knowing when async work finishes so it can batch and schedule change detection. It patches your async browser APIs, tracks when tasks start and finish, and triggers UI updates accordingly. This makes Angular apps feel reactive without you having to call detectChanges() everywhere. But it can also surprise you when third-party async code runs outside Angular’s zone, or when UI updates lag behind your expectations. Understanding NgZone’s role helps you debug those subtle UI update glitches and write more predictable, performant Angular apps. --- Helpful learning resources - MDN Web Docs - web.dev performance guidance - OpenAI developer resources Originally published at Under The Hood. Get the next deep dive in your inbox: subscribe to Under The Hood.
Angular 22+ projects are having an issue in Stackblitz currently, but while the larger issue is being fixed, try this workaround that Material added to their own starter: components#34030. Larger issue: angular-cli#34524. The workaround worked for my own projects.
My pull Angular CLI pull request has been merged! 🎉 After three review rounds with Alan Agius, prerender.format is now part of Angular itself. I'm a little proud: a community request from 2024 that never collected enough votes is now an official option of the CLI. angular-cli#34180
С Karma на Vitest: как мы обновляли unit-тесты Angular и починили Allure Привет! На связи Дмитрий Чепайкин, старший Angular-разработчик в команде Klecks — платформы для разметки данных AI Центра Т-Банка. Долгое время unit-тесты фронтенда у нас крутились на Karma + Jasmine. Схема работала, пайплайн зеленый, переписывать «ради моды» не хотелось. Но в какой-то момент Karma официально признали deprecated, а корректная отгрузка отчетов в Allure перестала быть чем-то, на что можно положиться. Расскажу, как мы выбирали замену, почему сначала смотрели на Jest, а в итоге приняли Vitest — и как устроен итоговый сетап. Триггеры миграции Во фронтенде Klecks три тестовых проекта: customer, operator и shared. Кодовая база включает сотни *.spec.ts, использует Angular TestBed, моки сервисов, фейковые таймеры и кастомные хелперы вроде UnhandledCatcher. При этом интеграционные сценарии уже давно выполнялись на Playwright, а unit-тестирование продолжало опираться на классический Angular-стек: Karma через builder @angular-devkit/build-angular:karma, Jasmine в качестве тестового API и Chrome Headless для запуска в CI. Покрытие кода собиралось через karma-coverage, а результаты unit-тестов пытались выгружать в Allure вместе с e2e-тестами. Формально все работало. Но стоимость поддержки стека росла, а на Karma уже не решалась надежно одна из задач качества — нормальная иерархия unit-тестов в Allure. Поэтому стоял вопрос не «Нужен ли нам новый раннер?», а «Какой раннер позволит уйти с deprecated-инструмента и при этом не развалить существующие тесты и отчетность?». Зачем трогать то, что работает Karma справлялась со своими задачами годами. Руки до миграции не доходили не потому, что стек был идеален, а потому, что он был «достаточно хорош». Локально ng test открывал браузер, в CI Chrome Headless прогонял suites. Менять инфраструктуру при живом продукте — всегда риск. Мы собрали список проблем. Deprecated и тупиковая поддержка. Karma больше не развивается как основной современный test runner, поэтому дальнейшая интеграция с актуальным инструментарием требует все больше поддержки legacy-слоя. Оставаться на Karma — значит тащить устаревающий слой между CLI и тестами, который рано или поздно упрется в несовместимость с новой версией Angular/Webpack/Vite. Allure для unit-тестов. Для e2e у нас уже был рабочий контур Playwright → Allure. Для unit на Karma отчеты не собирались, так как не было стабильного плагина, полноты и стабильности в выгрузке артефактов тестов. В CI это превращалось в «тесты зеленые, а в Allure unit-слой неполноценный». Именно эта боль стала триггером: отчетность — часть процесса, а не опциональный бантик. Тяжелый браузерный рантайм в CI. Karma поднимает реальный Chrome. Для части DOM-тестов это плюс, но для сотен unit-спеков — лишняя тяжесть образа, флаки по браузеру и более дорогой пайплайн. Cypress-образ ради unit-прогона сам по себе сигнал, что инфраструктура разъехалась с задачей. Jasmine-специфика. API привычный, но экосистема вокруг моков, таймеров и матчеров все сильнее расходится с тем, что используют Jest/Vitest-мир и современные Allure-репортеры. Любая новая интеграция требовала прокладок под Karma и Jasmine, а не готового first-class-плагина. Основными целями миграции стали отказ от устаревшего Karma, обеспечение корректной выгрузки результатов unit-тестов в Allure и сохранение существующей бизнес-логики тестов. Изменения должны были затронуть только инфраструктуру тестирования и API тестового раннера. Рассматривали: Jest vs Vitest Мы не прыгали сразу в финальный выбор. Сначала на ветке test_jest собрали полноценный прототип на Jest: jest-preset-angular, отдельные конфиги создали на customer / operator / shared, allure-jest, добавили адаптеры под Jasmine-совместимые хелперы (expectAsync, toBeTrue / toBeFalse, глобальный spyOn). Это был честный spike: можно ли перенести сотни спеков и закрыть Allure. Эксперимент с Jest показал, что можно закрыть основные боли: нативный Node + jsdom без Chrome; официальный путь через jest-preset-angular; рабочий allure-jest с иерархией лейблов; параллельный прогон проектов. Но именно для нашего Angular-монорепо появились минусы: отдельный контур мимо Angular builder — скрипты jest --config ... вместо ng test; свой зоопарк конфигов (create-jest-config, tsconfig.jest.json, project configs); необходимость compatibility-слоев под старый Jasmine API; трансформ ESM/CJS-зависимостей (transformIgnorePatterns на длинный список пакетов Taiga/Angular); два мира инструментов: CLI/сборка на одном стеке, тесты — на другом. Jest как технология нас не провалил. Он показал, что миграция с Karma возможна и что Allure на Node-раннере реально оживает. Но как целевой стек для Angular 21 он выглядел компромиссом, а не финальной точкой. В итоге мы выбрали Vitest. После Jest-spike мы собрали решение на Vitest — коммит с принятым направлением: миграция builders, конфигов и спеков. Выбор опирался на набор практических критериев. Интеграция Vitest через Analog позволила сохранить запуск через Angular CLI и стала подходящим вариантом для нашего текущего workspace. Vitest подключается через @analogjs/vitest-angular:test. Для разработчика вход тот же: ng test customer ng test operator --configuration=cisourcecraft.dev В angular.json вместо karma-builder: "builder": "@analogjs/vitest-angular:test", "options": { "configFile": "vitest.config.mts" }sourcecraft.dev Мы остаемся внутри Angular CLI, а не уходим в параллельную систему запуска. Миграция на Vitest для нас закрыла ряд вопросов. Vite-based pipeline вместо отдельного Jest transform layer. Vitest работает на Vite: быстрее старт, нативная работа с ESM, проще inline зависимостей Angular/Taiga через server.deps.inline. Там, где в Jest копился transformIgnorePatterns, в Vitest мы описываем resolve/alias и список пакетов для инлайна — и это лучше ложится на текущий фронтенд-стек. Прямая интеграция с Allure через allure-vitest. allure-vitest/reporter пишет результаты в allure-results/unit/<project>. Плюс общий setup автоматически проставляет иерархию: parentSuite → suite → layer → epic → subEpic. То есть unit-слой наконец встает рядом с интеграционными отчетами по тем же правилам качества. Меньше «клея», ближе к целевому API. Vitest из коробки дает vi.fn, vi.spyOn, fake timers, globals. Jasmine → Vitest — механическая замена в спеках, без отдельного compatibility-runtime, который пришлось бы сопровождать на Jest. CI без браузера и с контролем воркеров. Unit-джоба переехала на Node-образ. Воркеры ограничены явно: в CI VITEST_MAX_WORKERS=2, потому что Angular + jsdom + coverage прожорливы по RAM, а K8s часто отдает чужой CPU count. Это осознанная настройка под нашу инфраструктуру, а не дефолт «съешь все ядра». Вывод: Jest доказал миграцию, Vitest лучше сел как целевой раннер для Angular-приложений Klecks. Миграция самих тестов Большая часть работы — не переизобрести тесты, а перевести API. Вот типичные замены. Было (Jasmine) Стало (Vitest) jasmine.createSpy() vi.fn() spyOn(obj, 'method') vi.spyOn(obj, 'method') jasmine.createSpyObj(...) ручной stub / vi.fn на методах and.returnValue(x) mockReturnValue(x) and.callFake(fn) mockImplementation(fn) calls.reset() mockClear() / mockReset() jasmine.clock().install() vi.useFakeTimers() jasmine.clock().tick(n) vi.advanceTimersByTime(n) jasmine.objectContaining expect.objectContaining done()-колбэки async/await + Promise jasmine.Spy в типах Mock из vitest TestBed, ComponentFixture, провайдеры и структура describe/it почти не менялись. Важно, что миграция раннера не должна была стать рефакторингом продуктового кода. Мы отдельно подтянули инфраструктурные хелперы. Например, UnhandledCatcher научили ловить и window.onerror, и rxjs.config.onUnhandledError. В jsdom/Vitest необработанные ошибки Observable ведут себя иначе, чем в Karma/Chrome. Такие места всплывают только на реальном прогоне, поэтому spike на Jest тоже был полезен: часть проблем API мы увидели еще до финального выбора. Allure: как закрыли исходную боль Что мы сделали по шагам: Репортер пишет в allure-results/unit/<project>. Автолейблы строят иерархию из пути файла и имени теста. Epic выбирается по расположению: customer / operator / shared. Локально можно собрать и открыть отчет теми же npm-скриптами, что и для integration Параллелизм: Chrome Headless / Karma → Vitest threads, maxWorkers=2 В итоге unit-слой перестал быть «второй реальностью» относительно Playwright: и там и там Allure понимает слой, suite и продуктовый epic. Что принесла миграция на vitest. Было Стало Раннер Karma + Jasmine + Chrome Vitest + jsdom (+ Analog builder) Статус стека deprecated / legacy path Актуальный Angular-friendly-путь Запуск ng test через karma-builder ng test через @analogjs/vitest-angular:test Allure unit Ненадежная/неполная выгрузка allure-vitest + автолейблы CI-образ Тяжелый browser-based Node 22 Параллелизм Браузерный single-run контур threads + явный лимит воркеров Скорость джобы в CI ~3 минуты 40 секунд ~2 минуты 20 секунд Плюсы, которые мы получили: ушли с deprecated Karma; сохранили привычный DX через ng test; unit-отчеты в Allure стали частью нормального процесса; CI стал проще по зависимостям (без Chrome ради unit); спеки мигрируют механически, без переписывания сценариев; ускорили джобу unit тестов в CI на ~36,4%. Но есть и компромиссы. Они для нас— разумная цена за поддерживаемый стек и рабочую отчетность. Компромиссы, о которых стоит знать: jsdom ≠ полный браузер: редкие DOM/CSS edge-cases могут вести себя иначе; в CI нельзя бездумно крутить maxWorkers: coverage + Angular TestBed легко упираются в память; часть хелперов (UnhandledCatcher, fake timers) потребовала точечной адаптации под новый рантайм. Нативный Angular Vitest (@angular/build:unit-test) мы сознательно не брали, потому что для нашего кейса он слишком закрытый. Официальный builder заточен под zero-config: Angular сам поднимает Vitest, а кастомный vitest.config либо ограничен, либо поддерживается частично. Команда Angular прямо говорила: если нужен полноценный vitest.config.ts (плагины, репортеры, тонкая настройка), берите Analog. Для нас такой уровень контроля был принципиален. Требовалось интегрировать allure-vitest/reporter с собственной иерархией лейблов, создать единый factory-конфиг для customer, operator и shared с общими алиасами, путями Less и директориями покрытия. Кроме того, необходимо было управлять количеством воркеров через VITEST_MAX_WORKERS, чтобы учитывать ограничения памяти в K8s при запуске Angular-тестов с jsdom и coverage, а также гибко настраивать параметры рантайма, включая isolate, inline deps и setupFiles. Analog (@analogjs/vitest-angular) дает полный контроль «из коробки» и при этом оставляет ng test. Jest-spike уже показал, что «просто зеленых тестов» недостаточно — нужна управляемая инфраструктура и отчеты. Native имеет смысл для зеленого поля или простых приложений. Для нашего монорепо с Allure и жестким CI Analog оказался практичнее. Позже, когда @angular/build:unit-test догонит по конфигурируемости, можно будет пересмотреть — но на момент решения Analog закрывал требования лучше. Заключение Мы не меняли раннер, потому что «все переходят на Vitest». Триггером стала связка из двух фактов: Karma deprecated и Allure для unit на текущем стеке больше не закрывал требования качества. Jest на ветке test_jest подтвердил, что Node-раннер + Allure — рабочий путь. Vitest выбрали как целевое решение, потому что он лучше стыкуется с Angular CLI через Analog, естественнее живет с Vite/ESM и дает прямую интеграцию с Allure через allure-vitest без отдельной параллельной инфраструктуры запуска. Итог миграции: один shared Vitest-конфиг на три проекта; builder в angular.json без Karma; автолейблы Allure в @klecks/testing; механический перевод Jasmine API → Vitest в спеках; CI на Node с ограниченными воркерами; unit-тесты в CI проходят на ~36,4% быстрее; Если у вас похожий Angular-монолит на Karma и уже болит Allure/поддержка, имеет смысл сначала сделать короткий spike на Jest или Vitest именно с выгрузкой отчетов, а не только с «тесты зеленые локально». У нас зеленость никогда не была главной проблемой. Главным было сделать так, чтобы unit-слой снова был наблюдаемым и поддерживаемым.
github.com/angular/components/pulls Shuaib Hasan Akib done a lot of work in the Angular/Material docs to modernize their source code, among a lot of other things. If you look at open/closed PRs with him tagged in either repo (merged Material issues in this link) you will see all sorts of good refactors.
Shoutout to our community for an amazing contribution from Angelo Parziale. PR #70527 introduces strictUnclaimedEventNames, a new opt-in compiler option to catch misspelled output bindings by checking directive outputs and native events. Explore the PR: angular#70527 🛠
@mfpears Hey Mike, I know there are angular-developer skills (angular/skills), but I wonder if you had any that help Agents write declarative code. I feel like the general overview of signals or resource does not quite cut it
週刊プログラミングニュース|Deno・Python・OSSと開発ツール【2026-W42/10月9〜15日】 週刊プログラミングニュース|2026年10月9日〜15日 | 2026-W42 最初にDeno、Python、Bun、GitHub Actions、.NET、シークレット保護。その後、OSS・フレームワーク、ライブラリー・データベース、IDE・開発プラットフォームを章ごとに整理します。 対象期間:10月9日00:00〜15日23:59 JST。時刻を確認できた発表はJSTへ換算し、日付のみの資料は公式表記日を採用しています。境界時刻が未確認の候補は確定分と区別しました。 正式版、プレビュー、今後の予定を区別しています。Dockerは修正済み問題の開示、Nodeの10月21日予定分は未提供の修正予告です。 映像は公式情報に基づく解説図で、実機デモではありません。性能値は発表元の測定で、独自の実測ではありません。 日本語音声・日英字幕。 チャプター 00:00 週刊プログラミングニュース 00:28 DenoチームがCloudflareへ 01:50 Python 3.15が正式公開 03:18 Bun 1.4.3に型チェッカー 04:30 Actionsにワークフローロック 05:38 .NETは安定版更新とRC2 07:00 GitHubのシークレット検出拡大 07:59 Next.jsのセキュリティ更新 09:28 Express・Fastify・Micronaut 10:57 フロントエンドの安定版とRC 12:16 Astro・Rolldown・Laravel 13:21 Hydrogenがライブラリーへ 14:14 Pydantic・NumPy・FastAPI 15:59 PgBouncerとpsqlODBC 17:25 SQLite 3.54.0 18:38 Ruby 4.1のプレビュー 19:39 Node.js更新と次のセキュリティ修正 20:51 VS CodeとVisual Studio 22:07 Cursor Originのアプリ連携 22:54 GitLabの緊急修正 23:57 Docker Hubの修正済み問題の開示 25:04 Lambdaの新しい実行モデルと接続維持 26:27 今週の確認リスト 公式出典 deno.com blog.python.org bun.com github.blog devblogs.microsoft.com github.blog devblogs.microsoft.com github.com/dotnet/core/blob/main/release-notes/10… nextjs.org github.com/expressjs/express/releases/tag/v5.3.0 github.com/fastify/fastify/releases github.com/fastify/fastify/security/advisories/GH… micronaut.io micronaut.io github.com/angular/angular/releases/tag/v22.2.3 github.com/vuejs/core/releases github.com/vuejs/core/blob/main/CHANGELOG.md github.com/vuejs/core/blob/minor/CHANGELOG.md github.com/nuxt/nuxt/releases/tag/v4.6.1 github.com/sveltejs/kit/releases/tag/@sveltejs%2F… github.com/withastro/astro/releases/tag/astro@7.4… github.com/rolldown/rolldown/releases/tag/v1.2.14 github.com/laravel/framework/releases/tag/v13.36.0 github.com/laravel/framework/releases shopify.dev github.com/pydantic/pydantic/releases/tag/v2.14.0 pydantic.dev github.com/pydantic/pydantic/releases github.com/numpy/numpy/releases/tag/v2.5.4 github.com/fastapi/fastapi/releases/tag/0.143.1 github.com/fastapi/fastapi/releases/tag/0.143.2 pgbouncer.org github.com/pgbouncer/pgbouncer/releases/tag/pgbou… postgresql.org sqlite.org ruby-lang.org nodejs.org nodejs.org nodejs.org openwall.com code.visualstudio.com learn.microsoft.com cursor.com docs.gitlab.com docker.com aws.amazon.com developers.cloudflare.com
@Armandotrue ‘DeferredContent’ directive from Angular Aria does this and it’s beautiful. I use the exact same directive for my custom components as well. github.com/angular/components/blob/main/src/aria/…