Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(core): add experimental DomRef API
Add DomRef as a safer and more ergonomic API compared to ElementRef.

A DomRef is accessed by calling it, similar to a signal. Additionally, it ties result availability to rendering: it does not allow a reference to be unwrapped until after it has had an opportunity to render.
  • Loading branch information
devknoll committed Jun 21, 2024
commit 2e06ecf75ff8e67f3b5aad148181f6e2dbaba1f4
4 changes: 4 additions & 0 deletions goldens/public-api/core/errors.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export const enum RuntimeErrorCode {
// (undocumented)
DEFER_LOADING_FAILED = 750,
// (undocumented)
DOMREF_CONSTRUCTOR = 916,
// (undocumented)
DOMREF_NOT_READY = 915,
// (undocumented)
DUPLICATE_DIRECTIVE = 309,
// (undocumented)
EXPORT_NOT_FOUND = -301,
Expand Down
12 changes: 12 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,18 @@ export interface DoCheck {
ngDoCheck(): void;
}

// @public
export interface DomRef<T> {
// (undocumented)
readonly [DOMREF]: unknown;
// (undocumented)
(): T;
}

// @public (undocumented)
export class DomRef<T> {
}

// @public
export function effect(effectFn: (onCleanup: EffectCleanupRegisterFn) => void, options?: CreateEffectOptions): EffectRef;

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export {
export {ApplicationConfig, mergeApplicationConfig} from './application/application_config';
export {makeStateKey, StateKey, TransferState} from './transfer_state';
export {booleanAttribute, numberAttribute} from './util/coercion';
export {DomRef} from './linker/dom_ref';

import {global} from './util/global';
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export const enum RuntimeErrorCode {
COMPONENT_ID_COLLISION = -912,
IMAGE_PERFORMANCE_WARNING = -913,
UNEXPECTED_ZONEJS_PRESENT_IN_ZONELESS_MODE = 914,
DOMREF_NOT_READY = 915,
DOMREF_CONSTRUCTOR = 916,

// Signal integration errors
REQUIRED_INPUT_NO_VALUE = -950,
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/linker/dom_ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {RuntimeError, RuntimeErrorCode} from '../errors';
import {registerDomRefInitializer} from '../render3/after_render_hooks';
import {TNode} from '../render3/interfaces/node';
import {LView} from '../render3/interfaces/view';
import {getCurrentTNode, getLView} from '../render3/state';
import {getNativeByTNode} from '../render3/util/view_utils';

/**
* Symbol used to tell `DomRef`s apart from other functions.
*/
export const DOMREF = /* @__PURE__ */ Symbol('DOMREF');

/**
* A value that can be used to obtain a reference to a native DOM element.
*
* Prefer to use templating and data-binding provided by Angular instead.
* This API should be used as a last resort when direct DOM access is unavoidable.
*
* While a `DomRef` value may be passed around without restriction, attempting
* to obtain the native DOM element before the next time the application has
* rendered will throw an error. To avoid this, you should only unwrap a `DomRef`
* inside of `afterRender`, `afterNextRender`, or an event handler in response to a
* user interaction.
*
* @developerPreview
Comment thread
devknoll marked this conversation as resolved.
*/
// Note: This needs to be an interface. If it were a type, we would be
// unable to export the token class below with the same name, which
// would make it less ergonomic to use.
export interface DomRef<T> {
(): T;
readonly [DOMREF]: unknown;
}

/**
* Creates an DomRef from the most recent node.
*/
function injectDomRef(): DomRef<any> {
return createDomRef(getCurrentTNode()!, getLView());
}

function invalidDomRefGetter(): never {
throw new RuntimeError(
RuntimeErrorCode.DOMREF_NOT_READY,
ngDevMode &&
'Attempted to read DomRef before it was ready. Make sure that you are waiting ' +
'until the next render before reading.',
);
}

/**
* Creates a DomRef for the given node.
*/
function createDomRef<T>(tNode: TNode, lView: LView): DomRef<T> {
let getDomRefImpl: () => T = invalidDomRefGetter;
const getter = function getDomRef() {
return getDomRefImpl();
} as DomRef<T>;

const nativeElement = getNativeByTNode(tNode, lView) as T;
// Note: we don't use `internalAfterNextRender` here, as we want
// to ensure that the DomRef is only initialized imediately prior
// to the first user after*Render callback. Using `internalAfterNextRender`
// would risk exposing the native element too early.
registerDomRefInitializer(() => {
getDomRefImpl = () => nativeElement;
});

// We don't currently store anything on our symbol, but we need
// to provide it so that we can identify the function as a DomRef.
(getter as any)[DOMREF] = null;
return getter;
}

// This class acts as a DI token for DomRef.
export class DomRef<T> {
/** @internal */
constructor() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we make the constructor private instead of throwing ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we would still want to throw anyway, since you could still do new (DomRef as any)() to bypass the private constructor.

I think we'd really just want to declare DomRef as a function that throws:

export function DomRef<T>(): DomRef<T> {
  throw new RuntimeError(
    RuntimeErrorCode.DOMREF_CONSTRUCTOR,
    ngDevMode && 'DomRef cannot be called or constructed',
  );
}

But I think this might have some other issues.

throw new RuntimeError(
RuntimeErrorCode.DOMREF_CONSTRUCTOR,
ngDevMode && 'DomRef cannot not be constructed manually.',
);
}

/**
* @internal
* @nocollapse
*/
static __NG_ELEMENT_ID__: () => DomRef<any> = injectDomRef;
}
21 changes: 21 additions & 0 deletions packages/core/src/render3/after_render_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,20 @@ export function internalAfterNextRender(
afterRenderEventManager.internalCallbacks.push(callback);
}

/**
* Registers the given callback as a DomRef initializer, ensuring
* that it will be run prior to any user after*Render callbacks.
*/
export function registerDomRefInitializer<T>(callback: VoidFunction) {
const injector = inject(Injector);

// DomRef initializers are only registered on the browser.
if (!isPlatformBrowser(injector)) return;

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

/**
* Register callbacks to be invoked each time the application finishes rendering, during the
* specified phases. The available phases are:
Expand Down Expand Up @@ -744,11 +758,18 @@ export class AfterRenderEventManager {
/* @internal */
internalCallbacks: VoidFunction[] = [];

/* @internal */
domRefCallbacks: VoidFunction[] = [];

/**
* Executes internal and user-provided callbacks.
*/
execute(): void {
this.executeInternalCallbacks();
for (const domRefCallback of this.domRefCallbacks) {
domRefCallback();
}
this.domRefCallbacks.length = 0;
this.handler?.execute();
}

Expand Down
60 changes: 60 additions & 0 deletions packages/core/test/acceptance/dom_ref_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
import {Component, DomRef, PLATFORM_ID, afterNextRender, inject, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';

describe('DomRef', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}],
});
});

it('should initially throw', () => {
@Component({
template: ``,
standalone: true,
})
class MyComp {
elRef = inject(DomRef<HTMLElement>);

constructor() {
this.elRef();
}
}

expect(() => {
TestBed.createComponent(MyComp);
}).toThrowError(/Attempted to read DomRef before it was ready/);
});

it('should return the native element in afterRender', async () => {
@Component({
template: ``,
standalone: true,
})
class MyComp {
elRef = inject(DomRef<HTMLElement>);
el: HTMLElement | null = null;

constructor() {
afterNextRender(() => {
this.el = this.elRef();
});
}
}

const fixture = TestBed.createComponent(MyComp);
await fixture.whenStable();

expect(fixture.componentInstance.el).not.toBe(null);
expect(fixture.componentInstance.el).toBe(fixture.nativeElement);
});
});
3 changes: 3 additions & 0 deletions packages/core/test/bundling/defer/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,9 @@
{
"name": "init_dom2"
},
{
"name": "init_dom_ref"
},
{
"name": "init_dom_triggers"
},
Expand Down