Search code examples
javascriptclosurestemplate-strings

Problem with tagged template strings and closure


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();


Solution

  • 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.