Possible bug: Empty plain objects pick up a toString key when referenced in the view (until a key is added to them)

Hello,

I’m not sure if this is bug, or something happened to my environment (maybe Object was patched somewhere, though I can’t find such a patching), or maybe I’m just losing my mind.

When I initialize a class member, for example, like this:

this.someObject = {}

and then reference it in a get, like this:

get hasIssues() {
   return !!Object.keys(this.someObject).length;
}

it shows up like this the first time it’s accessed as, say, if.bind="model.hasIssues" in the view (hovering over it at a breakpoint in hasIssues() ):

The problem is that toString is seen as a key such that Object.keys(…).length returns a length of 1.

Is this intended? I don’t remember having this problem before installing Au 2.0 RC as it would have broken quite a bit of code. I’m not sure. toString is not there as a key once I add a key to it (it’s on the prototype).

I think it has to do with your handling of proxies.

Its a bug, the exclusion prop list need to be more extensive. Though itll take a few goes. Thanks @estaylorco

1 Like

Whew! I thought I was losing my mind!

This is what I did as a workaround (for others who are experiencing this):

get issuesAsList() {
  return Object.values(JSON.parse(JSON.stringify(this.someObject)));
}

Glad to see you resurface @bigopon ! I know you’ve been very busy with the router (I subscribe to GitHub notifications)…and with stabilizing RC.

Hello @bigopon ,

I just noticed that toJSON also winds up on an empty object, like toString. I use toJSON to customize the JSON that’s produced by my models.

@estaylorco how did you get toString tracked? In my test it isn’t touched with in Object.keyscall.

Hello @bigopon . I’ll need to explore this further. I was perplexed in the first place myself. toJSON I understand because it’s on the base class of all of my models…and I wrote it.

I have toString in three places, two of which are inside methods I don’t even call anywhere in my migration test target

The only place that toString could bubble into the view or the viewModel is in this value converter:

cssLength.js

import { valueConverter } from 'aurelia';

@valueConverter('cssLength')
export class CssLength {
  toView(numberOrString, uom = 'px') {
    return toCssLength(numberOrString, uom);
  }
}

export function toCssLength(numberOrString, uom = 'px') {
  let length;

  if (typeof numberOrString === 'string') {
    length = numberOrString + (parseInt(numberOrString, 10).toString() === numberOrString ? uom : '');
  } else if (typeof numberOrString === 'number') {
    length = numberOrString.toString(10) + uom;
  }

  return length;
}

I’ll work on it today. Any ideas what I could be doing myself to cause this?

the code you post above doesn’t seem to be causing it, so probably we will have to look at somewhere else. Or maybe assign a new toString fn to the object and return some lengthy string to see where it’s being used?

object.toString = () => '-'.repeat(1e5)

Continuing to explore this…

The problem I’m facing is that toString doesn’t always track. I haven’t even figured out where’s that’s originating, or when for that matter. If I do as you suggest, I would be manually creating the problem we’re trying solve…I think.

So, I turned my attention to other method that’s getting tracked: toJSON.

Before I explore further, toJSON is on my model base class, TsiModelBase. I’m convinced I’m not doing anything wrong, but I do believe I’m doing something different from what most are doing. A problem like this would case any testing of length on Object.keys(…) to fail. That would have hit the news by now.

I wonder if it’s the fact that I return a Proxy out of TsiModelBase. This is what I do in my Au 1.0 app to support change detection. Three hashes—current, previous, and original—control detecting changes during editing of a model, and auto-saving. Allow me to supply the entire code path:

TsiModelBase.js

import hashing from 'jshashes';
import {isEmpty as __isEmpty, pick as __pick} from 'lodash-es';
...
constructor() {
   ...
   this.hasher = new hashing.SHA1().b64;
   return new Proxy(this, this._proxyHandler()); // <-- Last line of ctor   
}

  
_proxyHandler() {
  return {
    set: function (obj, prop, newValue) {
      obj[prop] = newValue;

      if (!obj._suspendHashing && Object.hasOwn(obj, prop)) {
        obj._handleProxiedSet(obj, prop, newValue);
      }

      return true;
    },
  };
}

_handleProxiedSet(obj, prop, newValue) {
  obj._updateHashes.call(obj);
}

_updateHashes() {
  if (!this.hashes || __isEmpty(this.hashes)) return;

  const h = this.hashes;

  h.previousHash = h.currentHash;
  h.currentHash = this._computeHash();
}

_computeHash(o = this) {  
  return this.hasher(JSON.stringify(o));   // <-- This is where toJSON() would be accessed and possibly tracked
}

toJSON() {
  return __pick(this, Object.keys(this.defaults()));
}

defaults() {
  return {...}  // <-- An object with default properties and values that represent the "data" of the object for change detection purposes
}

Could proxying what I now realize is already a proxy be causing this problem? You weren’t using proxies in Au 1.0.

it’s hard to say. And you’ll probably have to add a few more logs here and there.

Regardless, back to our original issue where toString is being tracked, on a 2nd thought, I don’t think we can just change this behavior, since there’s no way to tell whether it’s intentional or not from the framework perspective. Same for toJSON. Maybe you can do something like this

import { ProxyObservable } from 'aurelia'

get myGetter() {
  raw = ProxyObservable.getRaw(this); // opt-out tracking
  ...
}

What behavior would you be changing? For toString and toJSON to become tracked when they’re both functions, such that they because keys on the object, that’s certainly nothing I’m doing intentionally. I wouldn’t even know how to do that without just setting a property.

This is causing Object.keys(…).length to return surprises all over the place.

In any case, I’ll continue to explore, of course.

@bigopon I believe I have some information that will help us track this down!

I think I was right to track down toJSON first, with the assumption that in uncovering the issue there, we might have a clue as to what’s going on with toString as well.

First, a few points:

  • I commented out the only instance of toJSON in my migration test target. This method is on TsiModelBase and is inherited by all models (in my migration test target, however, I have only one model).
  • I commented out the return of a Proxy out of TsiModelBase, which didn’t solve the problem. So we can rule out a proxy on a proxy as the root cause.
  • My preliminary conclusion is that it is not my toJSON that winds up tracked on the validation object (which is initialized as {} on TsiModelBase).

The following code is the culprit (I can comment out and comment back in to eliminate the tracked toJSON artifact, and produce it, respectively):

TsiModelBase.js

...

@computed((model) => model.validation, { deep: true })
get hasValidationErrors() {
  return Object.keys(JSON.parse(JSON.stringify(this.validation)))?.some((_prop) => !this.validation[_prop].isValid);
}

...

Now, it’s not the entirety of this code that’s causing the problem. It is specifically this:

JSON.parse(JSON.stringify(this.validation))

so that if I eliminate it, and go with this:

...

@computed((model) => model.validation, { deep: true })
get hasValidationErrors() {
  return Object.keys(this.validation)?.some((_prop) => !this.validation[_prop].isValid);
}

...

the problem goes away. There is no tracked toJSON artifact on the validation object.

So what is it about JSON.parse/JSON.stringify that’s causing the framework to track a toJSON artifact?

So what does this have to do with toString?

I introduced JSON.parse/JSON.stringify to eliminate the tracked toString artifact, which it did. But that caused the tracked toJSON artifact to appear.

I have a feeling that the root cause for both stray tracked artifacts is the same.

Does this help?

toJSON is a standard feature of JSON.stringify. When you do:

JSON.stringify({ a: 1, toJSON() { return { a: 2 } })

you get "{ “a”: 2 }” instead of "{ “a”: 1 }”. Because of that, calling JSON.stringify on an object will get toJSON tracked, I don’t know if there’s any safe assumption around this.

For toString, it’s probably something somewhere else, easiest to detect is you assign/override a toString() method on the object you are dealing with, and put a debugger inside

toString() {
  debugger;
  return "weird"
}

so you have a chance to see what’s the stack like, or look at the log to see where it might come from.

EDIT #2

It just occurred to me that maybe I’m not making myself clear on a particular point. I say this because a few times you’ve recommended setting debugger on a call to toString. The reason that’s not working is because the problem I’m calling out doesn’t involve a call to toString, but rather the adding of it to the object as a property.

So I wind up with an object that has toString on its prototype (as it should), and toString as a property of the object, like this:

const someObject = {
   ...
   toString: function(value) {...} // tracked
   ...
}

someObject.prototype.toString = function(value) {...}

I can tell you that that’s nothing I’m doing. I’ve never before seen toString on an object twice, not to my recollection.

That might explain your puzzlement in the question to me earlier: “How did you get toString to be tracked?”

EDIT #1

You asked me earlier how I got toString to track. Actually, I didn’t. I have no idea how it’s happening. I’m not doing anything with toString anywhere.

Perhaps what follows will help…

ORIGINAL

@bigopon I tried your suggestion concerning toString() and I didn’t get anywhere with it (but it was a good thought).

The problem surfaced again in a different context, which is great because now I have a basis for comparison. It occurs now both in the context of my validation framework and in a rewrite I’m working on of a list component. Both of them involve bindables that are initialized to a plain object, and where in the view and/or a computed property, I use bracket notation to access the value of a property.

I can make the following statement as a sort of premise:

In those cases where a bindable is a plain object initialized to {},and the object is used in a computed property, and the property does not exist, the bindable will pick up the following properties:

Screenshot 2026-02-08 at 2.44.07 PM

where toString is not only on the prototype, but also on the object itself such that Object.keys(someObject).length === 0 and isEmpty(someObject) are both false (isEmpty taken from lodash).

That explains in an earlier post why the problem was found in this computed on my model:

@computed((model) => model.validation, { deep: true })
get hasValidationErrors() {
  return Object.keys(this.validation)?.some((_prop) => this.validation[_prop]?.isValid === false);
}

I do believe it is this that’s causing the problem (perhaps confusing Aurelia’s tracking system):

this.validation[_prop]?.isValid

I say that because I have a very similar scenario in my handling of list selections:

get isSelected() {
  return this.selected[this.item.id] ?? false;
}

where selected is also a bindable plain object initialized to {}.

I don’t know how to help further without actually debugging it.

The reason I was suggesting adding to string/ or overriding to string was to check at which point toString was getting tracked. Another way to do it is to wrap the problematic object yourselves:

this.obj = new Proxy(this.obj, {
  get(target, key, receiver) {
    if (key === 'toString' && target.hasOwnProperty('toString')) {
      debugger; // where is this?
    }
  }
})

OK. That was a great idea! Perhaps we’ve gotten a little further on this.

I created a completely empty module within my project navigator. This is what I have for it as a test harness (this is the totality of the code for the test harness):

test-view.js

import { customElement } from 'aurelia';

@customElement('test-view')
export class TestView {
  constructor() {
    this.sourceObject = {};

    this.proxiedObject = new Proxy(this.sourceObject, {
      get(target, key, receiver) {
        if (key === 'toString' && target.hasOwnProperty('toString')) {
          debugger; // where is this?
        }
      },
    });
  }

  get hasValidationErrors() {
    return Object.keys(this.proxiedObject)?.some((_prop) => this.proxiedObject[_prop]?.isValid === false);
  }
}

and for the template:

test-view.html

<div class="tsi-bid-nav-view">Pricing</div>

<div>${hasValidationErrors}</div>

In the hasValidationErrors computed property, I set breakpoints just before keys in Object.keys, and just before some in Object.keys(this.proxiedObject)?.some(...). I also set a breakpoint on the condition line of the proxied get.

I hit the latter breakpoint first (in the proxied get), and never hit the other two breakpoints.

This is what I see:

So, I changed the condition to this:

this.proxiedObject = new Proxy(this.sourceObject, {
  get(target, key, receiver) {
    if (key.toString() === 'Symbol(Symbol.toStringTag)') { // <-- changed the condition
      debugger;
    }
  },
});

I had to eliminate target.hasOwnProperty(…) as that’s never satisfied without an __au_nw__ error.

With this change, I was able to hit the debugger line. Here are the screenshots (left and right, respectively):

Thanks, the stack helps. there is one line that mentions content binding which I think is the issue

it’s basically this:

image

So it’s probably originated in a text binding of yours. Somehow the value wasn’t probably unwrapped at the point the text binding tried to get the string value.

EDIT #1

OK. I think what you’re referring to is content-binding.ts. But that’s not my code.

So it’s probably originated in a text binding of yours.

In my post, I indicated that test-view.js and test-view.html are the only two files involved in testing for this issue. They might as well be in a repro on SB. Do you see a “text binding” anywhere in those two files that would produce this?

If not, then it’s a problem with the framework. If I stringify the plain object, the toString problem goes away, but the toJSON problem emerges.

Should I be putting together a repro?

ORIGINAL

I just searched my codebase. I don’t have this anywhere. Where’s it coming from? What would I be looking for?

What do you mean by content binding? I don’t have that anywhere either.

I meant I’m quite fixed on where the unintentional tracking of toString is, it’s probably from a text binding. For the reason that the binding receives a proxy wrapped object and thus .toString was tracked. I’m not sure at which point it passed proxied objects around, so maybe a repro would really help. We can also do a blanket fix where we always unwrap the values in content binding before trying to get the string, but maybe a repro would be best.

Ah, I see. I misunderstood. I will put together a repro tomorrow. I’m slammed today.

But at least we’re on it now!

Hello @bigopon ,

I have created a repro on Stackblitz at this link:

The call stack is different in devTools from what it is in VS Code. But I am hitting the debugger line successfully in the repro.

Please let me know if this helps.