Skip to content

Commit

Permalink
fix(upgrade): fix transclusion on upgraded components
Browse files Browse the repository at this point in the history
Previously, only simple, single-slot transclusion worked on upgraded components.
This commit fixes/adds support for the following:

- Multi-slot transclusion.
- Using fallback content when no transclusion content is provided.
- Destroy unused scope (when using fallback content).

This commit only affects `upgrade/static`. The dynamic version will be fixed in
a follow-up PR.

Fixes angular#11044
Fixes angular#13271
  • Loading branch information
gkalpak committed May 26, 2017
1 parent 97f1dcb commit ea371bf
Show file tree
Hide file tree
Showing 5 changed files with 566 additions and 130 deletions.
6 changes: 4 additions & 2 deletions packages/upgrade/src/common/angular1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ICompileService {
}
export interface ILinkFn {
(scope: IScope, cloneAttachFn?: ICloneAttachFunction, options?: ILinkFnOptions): IAugmentedJQuery;
$$slots?: {[slotName: string]: ILinkFn};
}
export interface ILinkFnOptions {
parentBoundTranscludeFn?: Function;
Expand Down Expand Up @@ -75,9 +76,10 @@ export interface IDirective {
templateUrl?: string|Function;
templateNamespace?: string;
terminal?: boolean;
transclude?: boolean|'element'|{[key: string]: string};
transclude?: DirectiveTranscludeProperty;
}
export type DirectiveRequireProperty = SingleOrListOrMap<string>;
export type DirectiveTranscludeProperty = boolean | 'element' | {[key: string]: string};
export interface IDirectiveCompileFn {
(templateElement: IAugmentedJQuery, templateAttributes: IAttributes,
transclude: ITranscludeFunction): IDirectivePrePost;
Expand All @@ -97,7 +99,7 @@ export interface IComponent {
require?: DirectiveRequireProperty;
template?: string|Function;
templateUrl?: string|Function;
transclude?: boolean;
transclude?: DirectiveTranscludeProperty;
}
export interface IAttributes { $observe(attr: string, fn: (v: string) => void): void; }
export interface ITranscludeFunction {
Expand Down
8 changes: 8 additions & 0 deletions packages/upgrade/src/common/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import {Type} from '@angular/core';
import * as angular from './angular1';

const DIRECTIVE_PREFIX_REGEXP = /^(?:x|data)[:\-_]/i;
const DIRECTIVE_SPECIAL_CHARS_REGEXP = /[:\-_]+(.)/g;

export function onError(e: any) {
// TODO: (misko): We seem to not have a stack trace here!
if (console.error) {
Expand All @@ -24,6 +27,11 @@ export function controllerKey(name: string): string {
return '$' + name + 'Controller';
}

export function directiveNormalize(name: string): string {
return name.replace(DIRECTIVE_PREFIX_REGEXP, '')
.replace(DIRECTIVE_SPECIAL_CHARS_REGEXP, (_, letter) => letter.toUpperCase());
}

export function getAttributesAsArray(node: Node): [string, string][] {
const attributes = node.attributes;
let asArray: [string, string][] = undefined !;
Expand Down
149 changes: 77 additions & 72 deletions packages/upgrade/src/static/upgrade_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {DoCheck, ElementRef, EventEmitter, Injector, OnChanges, OnDestroy, OnInit, SimpleChanges, ɵlooseIdentical as looseIdentical} from '@angular/core';
import * as angular from '../common/angular1';
import {$COMPILE, $CONTROLLER, $HTTP_BACKEND, $INJECTOR, $SCOPE, $TEMPLATE_CACHE} from '../common/constants';
import {controllerKey} from '../common/util';
import {controllerKey, directiveNormalize} from '../common/util';

const REQUIRE_PREFIX_RE = /^(\^\^?)?(\?)?(\^\^?)?/;
const NOT_SUPPORTED: any = 'NOT_SUPPORTED';
Expand Down Expand Up @@ -143,9 +143,8 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy {
}

ngOnInit() {
console.log('ngOnInit');
const attachChildNodes: angular.ILinkFn | undefined = this.prepareTransclusion(this.directive.transclude)
// Collect contents, insert and compile template
const attachChildNodes: angular.ILinkFn | undefined = this.prepareTransclusion(this.directive.transclude);
const linkFn = this.compileTemplate(this.directive);

// Instantiate controller
Expand Down Expand Up @@ -216,73 +215,6 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy {
}
}

private prepareTransclusion(transclude: any = false): angular.ILinkFn | undefined {
let childTranscludeFn: angular.ILinkFn | undefined;

if (transclude) {
const slots = Object.create(null);
let $template: angular.IAugmentedJQuery | Node[];

if (typeof transclude !== 'object') {
$template = this.extractChildNodes(this.element);
} else {
$template = [];

const slotMap = Object.create(null);
const filledSlots = Object.create(null);

// Parse the element selectors.
Object.keys(transclude).forEach(slotName => {
let selector = transclude[slotName];
const optional = selector.charAt(0) === '?';
selector = optional ? selector.substring(1) : selector;

slotMap[selector] = slotName;
slots[slotName] = null; // `null`: Defined but not yet filled.
filledSlots[slotName] = optional; // Consider optional slots as filled.
});


// Add the matching elements into their slot.
Array.prototype.forEach.call(this.$element.contents !(), (node: Element) => {
console.log(node);
const slotName = slotMap[directiveNormalize(node.nodeName.toLowerCase())];
if (slotName) {
filledSlots[slotName] = true;
slots[slotName] = slots[slotName] || [];
slots[slotName].push(node);
} else {
$template.push(node);
}
});

console.log(slots, filledSlots);

// Check for required slots that were not filled.
Object.keys(filledSlots).forEach(slotName => {
if (!filledSlots[slotName]) {
throw new Error(`Required transclusion slot '${slotName}' on directive: ${this.name}`);
}
});

Object.keys(slots)
.filter(slotName => slots[slotName])
.forEach(slotName => {
const slot = slots[slotName];
slots[slotName] = (scope: any, cloneAttach: any) => cloneAttach !(angular.element(slot), scope);
});
}

this.$element.empty !();

// default slot transclude fn
childTranscludeFn = (scope, cloneAttach) => cloneAttach !(angular.element($template as any));
(childTranscludeFn as any).$$slots = slots;

return childTranscludeFn;
}
}

ngOnChanges(changes: SimpleChanges) {
if (!this.bindingDestination) {
this.pendingChanges = changes;
Expand Down Expand Up @@ -399,6 +331,81 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy {
return bindings;
}

private prepareTransclusion(transclude: angular.DirectiveTranscludeProperty = false): angular.ILinkFn | undefined {
const contentChildNodes = this.extractChildNodes(this.element);
let $template = contentChildNodes;
let attachChildrenFn: angular.ILinkFn | undefined = (scope, cloneAttach) => cloneAttach !($template, scope);

if (transclude) {
const slots = Object.create(null);

if (typeof transclude === 'object') {
$template = [];

const slotMap = Object.create(null);
const filledSlots = Object.create(null);

// Parse the element selectors.
Object.keys(transclude).forEach(slotName => {
let selector = transclude[slotName];
const optional = selector.charAt(0) === '?';
selector = optional ? selector.substring(1) : selector;

slotMap[selector] = slotName;
slots[slotName] = null; // `null`: Defined but not yet filled.
filledSlots[slotName] = optional; // Consider optional slots as filled.
});

// Add the matching elements into their slot.
contentChildNodes.forEach(node => {
const slotName = slotMap[directiveNormalize(node.nodeName.toLowerCase())];
if (slotName) {
filledSlots[slotName] = true;
slots[slotName] = slots[slotName] || [];
slots[slotName].push(node);
} else {
$template.push(node);
}
});

// Check for required slots that were not filled.
Object.keys(filledSlots).forEach(slotName => {
if (!filledSlots[slotName]) {
throw new Error(`Required transclusion slot '${slotName}' on directive: ${this.name}`);
}
});

Object.keys(slots)
.filter(slotName => slots[slotName])
.forEach(slotName => {
const nodes = slots[slotName];
slots[slotName] = (scope: angular.IScope, cloneAttach: angular.ICloneAttachFunction) => cloneAttach !(nodes, scope);
});
}

// Attach `$$slots` to default slot transclude fn.
attachChildrenFn.$$slots = slots;

// AngularJS v1.6+ ignores empty or whitespace-only transcluded text nodes. But Angular
// removes all text content after the first interpolation and updates it later, after
// evaluating the expressions. This would result in AngularJS failing to recognize text
// nodes that start with an interpolation as transcluded content and use the fallback
// content instead.
// To avoid this issue, we add a
// [zero-width non-joiner character](https://en.wikipedia.org/wiki/Zero-width_non-joiner)
// to empty text nodes (which can only be a result of Angular removing their initial content).
// NOTE: Transcluded text content that starts with whitespace followed by an interpolation
// will still fail to be detected by AngularJS v1.6+
$template.forEach(node => {
if (node.nodeType === Node.TEXT_NODE && !node.nodeValue) {
node.nodeValue = '\u200C';
}
});
}

return attachChildrenFn;
}

private extractChildNodes(element: Element): Node[] {
const childNodes: Node[] = [];
let childNode: Node|null;
Expand Down Expand Up @@ -543,5 +550,3 @@ function isFunction(value: any): value is Function {
function isMap<T>(value: angular.SingleOrListOrMap<T>): value is {[key: string]: T} {
return value && !Array.isArray(value) && typeof value === 'object';
}

function directiveNormalize(id: string) { return id; }
5 changes: 3 additions & 2 deletions packages/upgrade/test/common/test_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export function html(html: string): Element {
return div;
}

export function multiTrim(text: string | null | undefined): string {
export function multiTrim(text: string | null | undefined, allSpace = false): string {
if (typeof text == 'string') {
return text.replace(/\n/g, '').replace(/\s\s+/g, ' ').trim();
const repl = allSpace ? '' : ' ';
return text.replace(/\n/g, '').replace(/\s+/g, repl).trim();
}
throw new Error('Argument can not be undefined.');
}
Expand Down
Loading

0 comments on commit ea371bf

Please sign in to comment.