Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"karma-jasmine-html-reporter": "^2.0.0",
"lint-staged": "10.5.3",
"ng-packagr": "^20.3.0",
"prettier": "2.2.1",
"prettier": "3.8.1",
"protractor": "~7.0.0",
"standard-version": "9.0.0",
"ts-node": "~8.3.0",
Expand Down
16 changes: 10 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 21 additions & 20 deletions projects/ngneat/overview/src/lib/teleport/teleport.directive.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { DestroyRef, Directive, EmbeddedViewRef, TemplateRef, effect, inject, model } from '@angular/core';
import { Subscription } from 'rxjs';
import { Directive, type EmbeddedViewRef, TemplateRef, effect, inject, model, untracked } from '@angular/core';

import { TeleportService } from './teleport.service';

Expand All @@ -9,34 +8,36 @@ import { TeleportService } from './teleport.service';
export class TeleportDirective {
readonly teleportTo = model<string | null | undefined>();

private viewRef: EmbeddedViewRef<any>;
private subscription: Subscription | null = null;
private viewRef: EmbeddedViewRef<any> | null = null;

private tpl = inject(TemplateRef);
private service = inject(TeleportService);

constructor() {
effect(() => {
effect((onCleanup) => {
const teleportTo = this.teleportTo();
if (!teleportTo) return;

this.dispose();

this.subscription = this.service.outlet$(teleportTo).subscribe((outlet) => {
if (outlet) {
this.viewRef = outlet.createEmbeddedView(this.tpl);
}
// outlet$() emits immediately if the outlet is already registered, or
// waits for it to appear — covering both sync and async outlet creation.
const subscription = this.service.outlet$(teleportTo).subscribe((outlet) => {
if (!outlet) return;

// untracked() prevents signals read during view creation (e.g. in the
// teleported template's directives) from being tracked as dependencies
// of this effect, which would cause spurious re-runs and view recreation.
this.viewRef = untracked(() => {
return outlet.createEmbeddedView(this.tpl);
});
});
});

inject(DestroyRef).onDestroy(() => {
this.dispose();
// onCleanup runs before the next effect execution and on directive destroy,
// ensuring the subscription and the remote view are always released together.
onCleanup(() => {
subscription.unsubscribe();
this.viewRef?.destroy();
this.viewRef = null;
});
});
}

private dispose(): void {
this.subscription?.unsubscribe();
this.subscription = null;
this.viewRef?.destroy();
}
}
85 changes: 46 additions & 39 deletions projects/ngneat/overview/src/lib/views/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
signal,
TemplateRef,
Type,
untracked,
ViewContainerRef,
WritableSignal,
} from '@angular/core';
Expand Down Expand Up @@ -44,7 +45,7 @@ export class ViewUnsupportedContentTypeError extends Error {
// injection token lets the component pull context from its injector without coupling
// to the caller's API or requiring a shared base class.
export const VIEW_CONTEXT = new InjectionToken<Signal<unknown>>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'Component context' : ''
typeof ngDevMode !== 'undefined' && ngDevMode ? 'Component context' : '',
);

@Injectable({ providedIn: 'root' })
Expand All @@ -54,41 +55,45 @@ export class ViewService {
private environmentInjector = inject(EnvironmentInjector);

createComponent<Comp, Context>(component: Type<Comp>, options: CompViewOptions<Context> = {}) {
let injector = options.injector ?? this.injector;
let contextSignal: WritableSignal<Context> | undefined;
return untracked(() => {
let injector = options.injector ?? this.injector;
let contextSignal: WritableSignal<Context> | undefined;

if (options.context) {
contextSignal = signal(options.context);
// Wrap the context in a child injector so the VIEW_CONTEXT token is only visible
// to this component and its descendants — not leaked into the broader injector tree.
injector = Injector.create({
providers: [
{
provide: VIEW_CONTEXT,
useValue: contextSignal.asReadonly(),
},
],
parent: injector,
});
}
if (options.context) {
contextSignal = signal(options.context);
// Wrap the context in a child injector so the VIEW_CONTEXT token is only visible
// to this component and its descendants — not leaked into the broader injector tree.
injector = Injector.create({
providers: [
{
provide: VIEW_CONTEXT,
useValue: contextSignal.asReadonly(),
},
],
parent: injector,
});
}

return new CompRef<Comp, Context>({
component,
vcr: options.vcr,
injector,
appRef: this.appRef,
environmentInjector: options.environmentInjector || this.environmentInjector,
contextSignal,
return new CompRef<Comp, Context>({
component,
vcr: options.vcr,
injector,
appRef: this.appRef,
environmentInjector: options.environmentInjector || this.environmentInjector,
contextSignal,
});
});
}

createTemplate<Context>(tpl: TemplateRef<Context>, options: TemplateViewOptions = {}) {
return new TplRef({
vcr: options.vcr,
appRef: this.appRef,
tpl,
context: options.context,
injector: options.injector,
return untracked(() => {
return new TplRef({
vcr: options.vcr,
appRef: this.appRef,
tpl,
context: options.context,
injector: options.injector,
});
});
}

Expand All @@ -100,15 +105,17 @@ export class ViewService {
createView(content: string): StringRef;
createView(content: Content, viewOptions?: ViewOptions): ViewRef;
createView<T extends Content, Context>(content: T, viewOptions: ViewOptions<Context> = {}): ViewRef {
if (isTemplateRef(content)) {
return this.createTemplate(content, viewOptions);
} else if (isComponent(content)) {
return this.createComponent(content, viewOptions);
} else if (isString(content)) {
return new StringRef(content);
} else {
throw new ViewUnsupportedContentTypeError();
}
return untracked(() => {
if (isTemplateRef(content)) {
return this.createTemplate(content, viewOptions);
} else if (isComponent(content)) {
return this.createComponent(content, viewOptions);
} else if (isString(content)) {
return new StringRef(content);
} else {
throw new ViewUnsupportedContentTypeError();
}
});
}
}

Expand Down