Skip to content

Commit

Permalink
refactor(core): Add helper function for queuing state updates (#54224)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
atscott authored and dylhunn committed Feb 27, 2024
1 parent 2aefed8 commit ffe3aa1
Show file tree
Hide file tree
Showing 2 changed files with 33 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export {setAlternateWeakRefImpl as ɵsetAlternateWeakRefImpl} from '../primitives/signals';
export {whenStable as ɵwhenStable} from './application/application_ref';
export {IMAGE_CONFIG as ɵIMAGE_CONFIG, IMAGE_CONFIG_DEFAULTS as ɵIMAGE_CONFIG_DEFAULTS, ImageConfig as ɵImageConfig} from './application/application_tokens';
export {queueStateUpdate as ɵqueueStateUpdate} from './render3/after_render_hooks';
export {internalCreateApplication as ɵinternalCreateApplication} from './application/create_application';
export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection';
export {getEnsureDirtyViewsAreAlwaysReachable as ɵgetEnsureDirtyViewsAreAlwaysReachable, setEnsureDirtyViewsAreAlwaysReachable as ɵsetEnsureDirtyViewsAreAlwaysReachable} from './change_detection/flags';
Expand Down
37 changes: 32 additions & 5 deletions packages/core/src/render3/after_render_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ import {assertNotInReactiveContext} from '../core_reactivity_export_internal';
import {assertInInjectionContext, Injector, ɵɵdefineInjectable} from '../di';
import {inject} from '../di/injector_compatibility';
import {ErrorHandler} from '../error_handler';
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {DestroyRef} from '../linker/destroy_ref';
import {assertGreaterThan} from '../util/assert';
import {performanceMarkFeature} from '../util/performance';
import {NgZone} from '../zone/ng_zone';

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

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

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

const afterRenderEventManager = injector.get(AfterRenderEventManager);
afterRenderEventManager.internalCallbacks.push(callback);
}

/**
* Queue a state update to be performed asynchronously.
*
* 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.
* 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.
*/
export function queueStateUpdate(callback: VoidFunction, options?: {injector?: Injector}): void {
!options && assertInInjectionContext(queueStateUpdate);

let executed = false;
const runCallbackOnce = () => {
if (executed) return;

executed = true;
callback();
};

const injector = options?.injector ?? inject(Injector);
internalAfterNextRender(runCallbackOnce, {injector, runOnServer: true});
queueMicrotask(() => {
runCallbackOnce();
});
}

/**
* Register a callback to be invoked each time the application
* finishes rendering.
Expand Down Expand Up @@ -435,6 +458,11 @@ export class AfterRenderEventManager {
* Executes callbacks. Returns `true` if any callbacks executed.
*/
execute(): void {
this.executeInternalCallbacks();
this.handler?.execute();
}

executeInternalCallbacks() {
// Note: internal callbacks power `internalAfterNextRender`. Since internal callbacks
// are fairly trivial, they are kept separate so that `AfterRenderCallbackHandlerImpl`
// can still be tree-shaken unless used by the application.
Expand All @@ -443,7 +471,6 @@ export class AfterRenderEventManager {
for (const callback of callbacks) {
callback();
}
this.handler?.execute();
}

ngOnDestroy() {
Expand Down

0 comments on commit ffe3aa1

Please sign in to comment.