Skip to content

Commit 152261c

Browse files
AndrewKushnirthePunderWoman
authored andcommitted
refactor(core): produce a message about @defer behavior when HMR is enabled (angular#60533)
When the HMR is enabled in Angular, all `@defer` block dependencies are loaded eagerly, instead of waiting for configured trigger conditions. From the DX perspective, it might be seen as an issue when all dependencies are being loaded eagerly. This commit adds a logic to produce a message into the console to provide more info for developers. PR Close angular#60533
1 parent b21c6e5 commit 152261c

File tree

7 files changed

+174
-4
lines changed

7 files changed

+174
-4
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# @defer behavior when HMR is enabled
2+
3+
Hot Module Replacement (HMR) is a technique used by development servers to avoid reloading the entire page when only part of an application is changed.
4+
5+
When the HMR is enabled in Angular, all `@defer` block dependencies are loaded
6+
eagerly, instead of waiting for configured trigger conditions (both for client-only and incremental hydration triggers). This is needed
7+
for the HMR to function properly, replacing components in an application at runtime
8+
without the need to reload the entire page. Note: the actual rendering of defer
9+
blocks respects trigger conditions in the HMR mode.
10+
11+
If you want to test `@defer` block behavior in development mode and ensure that
12+
the necessary dependencies are loaded when a triggering condition is met, you can
13+
disable the HMR mode as described in [`this document`](/tools/cli/build-system-migration#hot-module-replacement).

goldens/public-api/core/errors.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
// @public
88
export function formatRuntimeError<T extends number = RuntimeErrorCode>(code: T, message: null | false | string): string;
99

10+
// @public (undocumented)
11+
export function formatRuntimeErrorCode<T extends number = RuntimeErrorCode>(code: T): string;
12+
1013
// @public
1114
export class RuntimeError<T extends number = RuntimeErrorCode> extends Error {
1215
constructor(code: T, message: null | false | string);
@@ -29,6 +32,8 @@ export const enum RuntimeErrorCode {
2932
// (undocumented)
3033
CYCLIC_DI_DEPENDENCY = -200,
3134
// (undocumented)
35+
DEFER_IN_HMR_MODE = -751,
36+
// (undocumented)
3237
DEFER_LOADING_FAILED = -750,
3338
// (undocumented)
3439
DUPLICATE_DIRECTIVE = 309,

packages/core/src/application/application_ref.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import '../util/ng_hmr_mode';
910
import '../util/ng_jit_mode';
1011
import '../util/ng_server_mode';
1112

packages/core/src/defer/instructions.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ import {
6565
triggerResourceLoading,
6666
shouldAttachTrigger,
6767
} from './triggering';
68+
import {formatRuntimeError, RuntimeErrorCode} from '../errors';
69+
import {Console} from '../console';
70+
import {Injector} from '../di';
71+
72+
/**
73+
* Indicates whether we've already produced a warning,
74+
* prevents the logic from producing it multiple times.
75+
*/
76+
let _hmrWarningProduced = false;
77+
78+
/**
79+
* Logs a message into the console to indicate that `@defer` block
80+
* dependencies are loaded eagerly when the HMR mode is enabled.
81+
*/
82+
function logHmrWarning(injector: Injector) {
83+
if (!_hmrWarningProduced) {
84+
_hmrWarningProduced = true;
85+
const console = injector.get(Console);
86+
// tslint:disable-next-line:no-console
87+
console.log(
88+
formatRuntimeError(
89+
RuntimeErrorCode.DEFER_IN_HMR_MODE,
90+
'Angular has detected that this application contains `@defer` blocks ' +
91+
'and the hot module replacement (HMR) mode is enabled. All `@defer` ' +
92+
'block dependencies will be loaded eagerly.',
93+
),
94+
);
95+
}
96+
}
6897

6998
/**
7099
* Creates runtime data structures for defer blocks.
@@ -108,6 +137,10 @@ export function ɵɵdefer(
108137
if (tView.firstCreatePass) {
109138
performanceMarkFeature('NgDefer');
110139

140+
if (ngDevMode && typeof ngHmrMode !== 'undefined' && ngHmrMode) {
141+
logHmrWarning(injector);
142+
}
143+
111144
const tDetails: TDeferBlockDetails = {
112145
primaryTmplIndex,
113146
loadingTmplIndex: loadingTmplIndex ?? null,

packages/core/src/errors.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export const enum RuntimeErrorCode {
101101

102102
// Defer errors (750-799 range)
103103
DEFER_LOADING_FAILED = -750,
104+
DEFER_IN_HMR_MODE = -751,
104105

105106
// standalone errors
106107
IMPORT_PROVIDERS_FROM_STANDALONE = 800,
@@ -169,6 +170,13 @@ export class RuntimeError<T extends number = RuntimeErrorCode> extends Error {
169170
}
170171
}
171172

173+
export function formatRuntimeErrorCode<T extends number = RuntimeErrorCode>(code: T): string {
174+
// Error code might be a negative number, which is a special marker that instructs the logic to
175+
// generate a link to the error details page on angular.io.
176+
// We also prepend `0` to non-compile-time errors.
177+
return `NG0${Math.abs(code)}`;
178+
}
179+
172180
/**
173181
* Called to format a runtime error.
174182
* See additional info on the `message` argument type in the `RuntimeError` class description.
@@ -177,10 +185,7 @@ export function formatRuntimeError<T extends number = RuntimeErrorCode>(
177185
code: T,
178186
message: null | false | string,
179187
): string {
180-
// Error code might be a negative number, which is a special marker that instructs the logic to
181-
// generate a link to the error details page on angular.io.
182-
// We also prepend `0` to non-compile-time errors.
183-
const fullCode = `NG0${Math.abs(code)}`;
188+
const fullCode = formatRuntimeErrorCode(code);
184189

185190
let errorMessage = `${fullCode}${message ? ': ' + message : ''}`;
186191

packages/core/src/util/ng_hmr_mode.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
declare global {
10+
/**
11+
* Indicates whether HMR is enabled for the application.
12+
*
13+
* `ngHmrMode` is a global flag set by Angular's CLI.
14+
*
15+
* @remarks
16+
* - **Internal Angular Flag**: This is an *internal* Angular flag (not a public API), avoid relying on it in application code.
17+
* - **Avoid Direct Use**: This variable is intended for runtime configuration; it should not be accessed directly in application code.
18+
*/
19+
var ngHmrMode: boolean | undefined;
20+
}
21+
22+
// Export an empty object to ensure this file is treated as an ES module, allowing augmentation of the global scope.
23+
export {};

packages/core/test/acceptance/defer_spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import {ActivatedRoute, provideRouter, Router, RouterOutlet} from '@angular/rout
4242
import {ChainedInjector} from '../../src/render3/chained_injector';
4343
import {global} from '../../src/util/global';
4444
import {TimerScheduler} from '@angular/core/src/defer/timer_scheduler';
45+
import {Console} from '../../src/console';
46+
import {formatRuntimeErrorCode, RuntimeErrorCode} from '../../src/errors';
4547

4648
/**
4749
* Clears all associated directive defs from a given component class.
@@ -130,6 +132,25 @@ class FakeTimerScheduler {
130132
}
131133
}
132134

135+
@Injectable()
136+
export class DebugConsole extends Console {
137+
logs: string[] = [];
138+
override log(message: string) {
139+
this.logs.push(message);
140+
}
141+
override warn(message: string) {
142+
this.logs.push(message);
143+
}
144+
}
145+
146+
/**
147+
* Provides a debug console instance that allows to capture all
148+
* produces messages for testing purposes.
149+
*/
150+
export function withDebugConsole() {
151+
return [{provide: Console, useClass: DebugConsole}];
152+
}
153+
133154
/**
134155
* Given a template, creates a component fixture and returns
135156
* a set of helper functions to trigger rendering of prefetching
@@ -527,6 +548,75 @@ describe('@defer', () => {
527548
});
528549
});
529550

551+
describe('with HMR', () => {
552+
beforeEach(() => {
553+
globalThis['ngHmrMode'] = true;
554+
});
555+
556+
afterEach(() => {
557+
globalThis['ngHmrMode'] = undefined;
558+
});
559+
560+
it('should produce a message into a console about eagerly loaded deps', async () => {
561+
@Component({
562+
selector: 'simple-app',
563+
template: `
564+
@defer (when true) {
565+
Defer block #1
566+
}
567+
@defer (on immediate) {
568+
Defer block #2
569+
}
570+
@defer (when true) {
571+
Defer block #3
572+
}
573+
`,
574+
})
575+
class MyCmp {}
576+
577+
TestBed.configureTestingModule({providers: [withDebugConsole()]});
578+
const fixture = TestBed.createComponent(MyCmp);
579+
fixture.detectChanges();
580+
581+
// Wait for all async actions to complete.
582+
await allPendingDynamicImports();
583+
fixture.detectChanges();
584+
585+
// Make sure that the HMR message is present in the console and there is
586+
// only a single instance of a message.
587+
const console = TestBed.inject(Console) as DebugConsole;
588+
const errorCode = formatRuntimeErrorCode(RuntimeErrorCode.DEFER_IN_HMR_MODE);
589+
const hmrMessages = console.logs.filter((log) => log.indexOf(errorCode) > -1);
590+
expect(hmrMessages.length).withContext('HMR message should be present once').toBe(1);
591+
592+
const textContent = fixture.nativeElement.textContent;
593+
expect(textContent).toContain('Defer block #1');
594+
expect(textContent).toContain('Defer block #2');
595+
expect(textContent).toContain('Defer block #3');
596+
});
597+
598+
it('should not produce a message about eagerly loaded deps if no defer blocks are present', () => {
599+
@Component({
600+
selector: 'simple-app',
601+
template: `No defer blocks`,
602+
})
603+
class MyCmp {}
604+
605+
TestBed.configureTestingModule({providers: [withDebugConsole()]});
606+
const fixture = TestBed.createComponent(MyCmp);
607+
fixture.detectChanges();
608+
609+
// Make sure that there were no HMR messages present in the console, because
610+
// there were no defer blocks in a template.
611+
const console = TestBed.inject(Console) as DebugConsole;
612+
const errorCode = formatRuntimeErrorCode(RuntimeErrorCode.DEFER_IN_HMR_MODE);
613+
const hmrMessages = console.logs.filter((log) => log.indexOf(errorCode) > -1);
614+
expect(hmrMessages.length).withContext('HMR message should *not* be present').toBe(0);
615+
616+
expect(fixture.nativeElement.textContent).toContain('No defer blocks');
617+
});
618+
});
619+
530620
describe('`on` conditions', () => {
531621
it('should support `on immediate` condition', async () => {
532622
@Component({

0 commit comments

Comments
 (0)