Symbol.toPrimitive
method, invoked within tagged template literal loses access to the closure.
To reproduce, simply paste the provided code snippet in dev console, run it with and without tag-function. Any relevant articles are highly appreciated.
P.S. I would also appreciate if you give me an idea how and where to debug js code (including node.js). I'm interested in lexical environment, execution context and call stack.
const isEmptyString = /^\s*$/;
class Thread {
constructor() {
this.scope = {
current: '/test|0::0'
};
this.context = {
current: '/test|0'
};
this.html = (strings, ...interpolations) => {
var output = '';
var prevMode = this._mode;
this._mode = 'html';
var {
length
} = interpolations;
output += strings[0]
for (let i = 0; i < length; ++i) {
output += String(interpolations[i]) + strings[i + 1];
}
this._mode = prevMode;
return output;
};
}
get id() {
var fragment;
const scope = this.scope.current;
const context = this.context.current;
return Object.defineProperties(function self(newFragment) {
fragment = newFragment;
return self;
}, {
scope: {
get() {
return scope
}
},
context: {
get() {
return context
}
},
fragment: {
get() {
return fragment
}
},
[Symbol.toPrimitive]: {
value: hint => {
console.log('::', fragment, '::');
const isFragmentDefined = !isEmptyString.test(fragment);
const quote = isFragmentDefined ? '\'' : '';
const suffix = isFragmentDefined ? `::${fragment}` : '';
if (isFragmentDefined) fragment = '';
switch (true) {
case this._mode === 'html':
return `node=${quote}${scope}${suffix}${quote}`;
case this._mode === 'css':
return `${context}${suffix}`.replace(invalidCSS, char => `\\${char}`);
default:
return `${scope}${suffix}`;
}
}
}
});
}
}
let thread = new Thread();
async function article() {
let {
id,
html
} = thread;
let links = html `
<ul>
<li ${id('C-first-id')}></li>
<li ${id('C-second-id')}></li>
<li ${id('C-third-id')}></li>
<li ${id('C-fourth-id')}></li>
</ul>
`;
return html `
<article>
<h1 ${id('B-first-id')}>Some header</h1>
<p ${id('B-second-id')}>Lorem ipsum...</p>
<p ${id('B-third-id')}>Lorem ipsum...</p>
<p ${id('B-fourth-id')}>Lorem ipsum...</p>
<section>
${links}
</section>
</article>
`;
}
async function content() {
let {
id,
html
} = thread;
return html `
<main>
<div>
<h1 ${id('A-first-id')}>Last article</h1>
<div>
<a href='#' ${id('A-second-id')}>More articles like this</a>
${await article()}
<a href='#' ${id('A-third-id')}>Something else...</a>
<a href='#' ${id('A-fourth-id')}>Something else...</a>
</div>
</div>
</main>
`;
}
content();
I'm not sure that I understand what you mean.
After some comments below the confusion about what "run it with and without tag-function" meant:
let { id, html } = thread;
console.log("Without tag function", `${id("A-first-id")}${id("A-second-id")}${id("A-third-id")}`);
console.log("With tag function", html`${id("A-first-id")}${id("A-second-id")}${id("A-third-id")}`);
The result is:
Without tag function /test|0::0::A-first-id/test|0::0::A-second-id/test|0::0::A-third-id
With tag function node='/test|0::0::A-third-id'node=/test|0::0node=/test|0::0
The difference is that without the tag-function, it works as intended, and "A-first-id", "A-second-id", and "A-third-id" is present in the result. When using the tag-function, only "A-third-id" is present (and the format is also different).
The question is why "A-first-id" and "A-second-id" are lost when using it with the tag-function.
But I noticed that you overwrite fragment every time that you call id
, and the code in Symbol.toPrimitive
is called at a later time. That is why you only get the last string "[ABC]-fourth-id"
and you clear the fragment with if (isFragmentDefined) fragment = '';
"use strict";
class Thread {
constructor() {
this.html = (strings, ...interpolations) => {
var output = '';
var {
length
} = interpolations;
output += strings[0]
for (let i = 0; i < length; ++i) {
output += String(interpolations[i]) + strings[i + 1];
}
return output;
};
}
get id() {
var fragment;
return Object.defineProperties(function self(newFragment) {
console.log("fragment new '%s' old '%s'", newFragment, fragment);
fragment = newFragment; // overwrite fragment
return self;
}, {
[Symbol.toPrimitive]: {
value: hint => {
// this is called later, fragment is the last value
console.log("toPrimitive", fragment);
return fragment;
}
}
});
}
}
let thread = new Thread();
async function content() {
let {
id,
html
} = thread;
return html `
${id('A-first-id')}
${id('A-second-id')}
${id('A-third-id')}
${id('A-fourth-id')}
`;
}
content().then(x => console.log(x));
Run the code above and you get:
fragment new 'A-first-id' old 'undefined'
fragment new 'A-second-id' old 'A-first-id'
fragment new 'A-third-id' old 'A-second-id'
fragment new 'A-fourth-id' old 'A-third-id'
toPrimitive A-fourth-id
toPrimitive A-fourth-id
toPrimitive A-fourth-id
toPrimitive A-fourth-id
A-fourth-id
A-fourth-id
A-fourth-id
A-fourth-id
So first the code in id
is called for EVERY occurrence in your string, overwriting fragment
every time. After that, toPrimitive
is called, and it only has the last fragment set: "A-fourth-id"
.
I'm pretty sure that this wasn't what you wanted.
I think that you wanted:
fragment new 'A-first-id' old 'undefined'
fragment new 'A-second-id' old 'A-first-id'
fragment new 'A-third-id' old 'A-second-id'
fragment new 'A-fourth-id' old 'A-third-id'
toPrimitive A-first-id
toPrimitive A-second-id
toPrimitive A-third-id
toPrimitive A-fourth-id
A-first-id
A-second-id
A-third-id
A-fourth-id
And the real bug is...
When I was looking at the code again and tried to explain why fragment was overwritten it hit me: you define id
as a getter. So when you do:
let { id, html } = thread;
you are actually calling the code in id
, and you get the function. So every time you use id
in your string, it uses the same function with the same fragment.
The solution? Refactor your code so that id
isn't a getter.
When you are using deconstructing of functions from an object, the function no longer knows the context. You can fix that, by binding the function in the constructor:
class MyClass {
constructor() {
// Bind this to some functions
for (const name of ['one', 'two'])
this[name] = this[name].bind(this);
}
one(value) {
return this.two(value).toString(16);
}
two(value) {
return value * 2;
}
}
const my = new MyClass();
const {one, two} = my;
console.log(one(1000)); // Works since `one` was bound in the constructor
And for debugging:
Update
Tag-functions for template strings is just syntactic sugar for passing arguments to a function.
let { id, html } = thread;
// A tag function is just syntactic sugar:
html`${id("A-first-id")}${id("A-second-id")}${id("A-third-id")}`;
// for this:
html(["", "", "", ""], id("A-first-id"), id("A-second-id"), id("A-third-id"));
Without the syntactic sugar it is obvious that you overwrite the fragment each time that you call id, and only the last value will be used when converted to a primitive value.
When you don't use the tag-function, each value is converted to the primitive value at each place in the template string. But when you use it with a tag-function, you get each value as a parameter to your tag-function, and the conversion to the primitive value dosen't happen until you convert it in the tag function. Therefore you only get the last value of your fragment.