Skip to content

Commit ffe3aa1

Browse files
atscottdylhunn
authored andcommitted
refactor(core): Add helper function for queuing state updates (#54224)
This adds a helper function to defer application state updates to the first possible "safe" moment. If application-wide change detection (ApplicationRef.tick) is currently executing when this function is used, the callback will execute as soon as all views attached to the `ApplicationRef` have been refreshed. Refreshing the application views will happen again before `checkNoChanges` executes. When a change detection is _not_ running, this state update will execute in the microtask queue. This function is necessary as a replacement for current `Promise.resolve().then(() => stateUpdate())` to be zoneless compatible while ensuring those state updates are synchronized to the DOM before the browser repaint. Without this, updates done in `Promise.resolve(...)` would queue another round of change detection in zoneless applications, and this change detection could happen in the next browser frame, and cause noticeable flicker for the user. Additionally, this function provides a way to perform state updates that will run on the server as well as in the browser. Last, current applications using `ngZone: 'noop'` may not be calling `ApplicationRef.tick` at all so this function provides a mechanism to ensure the state update still happens by racing a microtask with `afterNextRender` (which might never execute). PR Close #54224
1 parent 2aefed8 commit ffe3aa1

File tree

2 files changed

+33
-5
lines changed

2 files changed

+33
-5
lines changed

packages/core/src/core_private_export.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
export {setAlternateWeakRefImpl as ɵsetAlternateWeakRefImpl} from '../primitives/signals';
1010
export {whenStable as ɵwhenStable} from './application/application_ref';
1111
export {IMAGE_CONFIG as ɵIMAGE_CONFIG, IMAGE_CONFIG_DEFAULTS as ɵIMAGE_CONFIG_DEFAULTS, ImageConfig as ɵImageConfig} from './application/application_tokens';
12+
export {queueStateUpdate as ɵqueueStateUpdate} from './render3/after_render_hooks';
1213
export {internalCreateApplication as ɵinternalCreateApplication} from './application/create_application';
1314
export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection';
1415
export {getEnsureDirtyViewsAreAlwaysReachable as ɵgetEnsureDirtyViewsAreAlwaysReachable, setEnsureDirtyViewsAreAlwaysReachable as ɵsetEnsureDirtyViewsAreAlwaysReachable} from './change_detection/flags';

packages/core/src/render3/after_render_hooks.ts

+32-5
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import {assertNotInReactiveContext} from '../core_reactivity_export_internal';
1010
import {assertInInjectionContext, Injector, ɵɵdefineInjectable} from '../di';
1111
import {inject} from '../di/injector_compatibility';
1212
import {ErrorHandler} from '../error_handler';
13-
import {RuntimeError, RuntimeErrorCode} from '../errors';
1413
import {DestroyRef} from '../linker/destroy_ref';
15-
import {assertGreaterThan} from '../util/assert';
1614
import {performanceMarkFeature} from '../util/performance';
1715
import {NgZone} from '../zone/ng_zone';
1816

@@ -129,6 +127,7 @@ export interface InternalAfterNextRenderOptions {
129127
* If this is not provided, the current injection context will be used instead (via `inject`).
130128
*/
131129
injector?: Injector;
130+
runOnServer?: boolean;
132131
}
133132

134133
/** `AfterRenderRef` that does nothing. */
@@ -156,13 +155,37 @@ export function internalAfterNextRender(
156155
const injector = options?.injector ?? inject(Injector);
157156

158157
// Similarly to the public `afterNextRender` function, an internal one
159-
// is only invoked in a browser.
160-
if (!isPlatformBrowser(injector)) return;
158+
// is only invoked in a browser as long as the runOnServer option is not set.
159+
if (!options?.runOnServer && !isPlatformBrowser(injector)) return;
161160

162161
const afterRenderEventManager = injector.get(AfterRenderEventManager);
163162
afterRenderEventManager.internalCallbacks.push(callback);
164163
}
165164

165+
/**
166+
* Queue a state update to be performed asynchronously.
167+
*
168+
* This is useful to safely update application state that is used in an expression that was already checked during change detection. This defers the update until later and prevents `ExpressionChangedAfterItHasBeenChecked` errors. Using signals for state is recommended instead, but it's not always immediately possible to change the state to a signal because it would be a breaking change.
169+
* When the callback updates state used in an expression, this needs to be accompanied by an explicit notification to the framework that something has changed (i.e. updating a signal or calling `ChangeDetectorRef.markForCheck()`) or may still cause `ExpressionChangedAfterItHasBeenChecked` in dev mode or fail to synchronize the state to the DOM in production.
170+
*/
171+
export function queueStateUpdate(callback: VoidFunction, options?: {injector?: Injector}): void {
172+
!options && assertInInjectionContext(queueStateUpdate);
173+
174+
let executed = false;
175+
const runCallbackOnce = () => {
176+
if (executed) return;
177+
178+
executed = true;
179+
callback();
180+
};
181+
182+
const injector = options?.injector ?? inject(Injector);
183+
internalAfterNextRender(runCallbackOnce, {injector, runOnServer: true});
184+
queueMicrotask(() => {
185+
runCallbackOnce();
186+
});
187+
}
188+
166189
/**
167190
* Register a callback to be invoked each time the application
168191
* finishes rendering.
@@ -435,6 +458,11 @@ export class AfterRenderEventManager {
435458
* Executes callbacks. Returns `true` if any callbacks executed.
436459
*/
437460
execute(): void {
461+
this.executeInternalCallbacks();
462+
this.handler?.execute();
463+
}
464+
465+
executeInternalCallbacks() {
438466
// Note: internal callbacks power `internalAfterNextRender`. Since internal callbacks
439467
// are fairly trivial, they are kept separate so that `AfterRenderCallbackHandlerImpl`
440468
// can still be tree-shaken unless used by the application.
@@ -443,7 +471,6 @@ export class AfterRenderEventManager {
443471
for (const callback of callbacks) {
444472
callback();
445473
}
446-
this.handler?.execute();
447474
}
448475

449476
ngOnDestroy() {

0 commit comments

Comments
 (0)