Копание и отладка помогли прийти к такому коду handleModuleRef
function handleModuleRef(moduleRef: NgModuleRef<{}>, callback: Function, req, res) {
const state = moduleRef.injector.get(PlatformState);
const appRef = moduleRef.injector.get(ApplicationRef);
const router = appRef.components[0].instance.router;
const zone = appRef.components[0].instance.zone;
zone.run(() => {
router.navigateByUrl(req.originalUrl);
});
appRef.isStable
.filter((isStable: boolean) => isStable)
.first()
.subscribe((stable) => {
const bootstrap = moduleRef.instance['ngOnBootstrap'];
bootstrap && bootstrap();
if (!res || !res.finished) callback(null, state.renderToString());
});
}
В главный компонент инжектим Router и NgZone чтобы потом можно было до них достучаться.
Мы зашли в метод handleModuleRef и если с первым запросом всё понятно - приложение нестабильно (isStable=false), то с последующими было неясно как его дестабилизировать чтобы запустить цикл ChangeDetection. Как мы знаем ChangeDetection работает сверху вниз по иерархии то есть от главного компонента на все низлежащие, то есть если у нас в приложении N компонент, то сложность такой операции O(N). Таким образом мы можем дестабилизировать приложение напрямую сообщив новый URL для роутера который мы получили из главного компонента. Фокус заключается в том, что isStable - это по сути свойство зоны NgZone (которая одна на всё приложение) и нам нужно дестабилизировать её. Поэтому код роутера следует запускать внутри zone.run(...). Приложение хавает новый маршрут, зоны дестабилизируется и мы ждем пока она станет стабильной (то есть в очереди внутри зоны не будет ни одной таски). Рендерим. Профит!
Последствия:
1) аналогичные браузеру - со временем течет память, спасает то что запущено всё в докере и всё само поднимается;
2) постоянный и не совсем предсказуемый state всего приложения.
Скорость рендеринга ускорилась в среднем 2-3-4 раза на разных компонентах по своему.