I took the time to investigate inside-out the issue ... basically the example proposed is that new Foo has a new Bar that is lazily initialized with that new Foo "container" ... this serializes fine (new Foo is recursive via Foo->Bar->Circular) but unserialize breaks because Foo needs Bar to unserialize, but Bar needs Foo to be constructed.
The gotcha is evil on purpose because you cannot recreate that new Foo directly, you need to create new Bar a part and then call initBar on foo but you want the result to be one shot:
- 3 operations to create the problematic
foobut ... - 2 operations to restore one shot?
Your fooFromClonePayload wants to recreate itself as if bar was possible as constructor but it's kinda obvious that cannot possibly work, the constructor design is broken in there ... here a better example:
class Foo {
#bar;
get bar() {
return this.#bar;
}
initBar(bar) {
if (this.#bar !== undefined) throw Error();
this.#bar = bar;
}
static isFoo(foo) {
return #bar in foo;
}
static asClonePayload(foo) {
return { bar: foo.#bar.foo === foo ? null : foo.#bar };
}
static fromClonePayload(data) {
const foo = new Foo();
foo.initBar(data.bar ?? new Bar(foo));
return foo;
}
}
class Bar {
#foo;
constructor(foo) {
this.#foo = foo;
}
get foo() {
return this.#foo;
}
static isBar(bar) {
return #foo in bar;
}
static asClonePayload(bar) {
return { foo: bar.#foo };
}
static fromClonePayload(data) {
return new Bar(data.foo);
}
}
I believe this is the correct implementation and it should work ... it does in flatted-view I think it'd do even with my JSON registry proposal.
If the argument is "what should we do in such cases?" I think throwing is fine because these cases simply break the construction/destruction pattern and are kinda pointless to me as examples when the contract is clear: you provide a way to recreate an instance ... if that instance can be cyclic due lazy steps pollution of its fields you gotta deal with it ... would that work?