A JavaScript Lifecycle-Trap — How Class Field Initialisers Break Inheritance
If you are one of the people that love to optimise away a constructor of a subclass, beware of a subtle caveat. The class field initialisation may not take place, when you expect it.
Background
I am currently migrating @pdf-lib/fontkit to TypeScript. In the process, I introduced an undesired change in runtime behaviour that was really hard to spot.
The base class for all fonts is SFNTFont. The constructor does more or less nothing but only defines a getter for each known font table of an SFNT font. Decoding the font tables happens later as needed.
A WOFF2Font has roughly the same structure as TrueType/OpenType fonts, but the font data is compressed with the Brotli algorithm.
Let's create a simplified version of the code that showcases the problem. If you want to see the real code or are interested in a TypeScript version of fontkit, see pdf-lab.
// sfnt-font.js
export class SFNTFont {
constructor(data) {
this.data = data;
console.log(`decompressed flag in constructor: ${this.decompressed}`);
// Example for a getter.
Object.defineProperty(this, 'hmtx', {
get: () => this.getTable('hmtx'),
});
// This piece of debugging code caused the problem:
console.log(this.hmtx);
}
getTable(name) {
// "Decode" this.data into the font table `name` ...
return `table data: ${this.data}`;
}
}The console.log() statement in line 6 will make sense later. It should show us that the property decompressed is undefined.
The other console.log() statement will print the "decoded" table.
And now a little script that uses the class.
// run.js
import { SFNTFont } from './sfnt-font.js';
console.log('creating font');
const font = new SFNTFont('abcxyz');
console.log('font instantiated');
console.log(font.getTable('hmtx'));Execute it with node ./run.js and see the output:
creating font
decompressed flag in constructor: undefined
table data: abcxyz
font instantiated
table data: abcxyzThis is expected. The decompressed flag is not defined. The constructor calls this.getTable() and prints it. And then the script run.js calls font.getTable() and gets the same.
With Inheritance
Now the WOFF2Font subclass comes into play:
// woff2-font.js
import { SFNTFont } from './sfnt-font.js';
export class WOFF2Font extends SFNTFont {
decompressed = false;
getTable(name) {
console.log(`decompress flag before: ${this.decompressed}`);
if (!this.decompressed) {
// Decompress the data.
this.data = this.decompress();
this.decompressed = true;
}
console.log(`decompress flag after: ${this.decompressed}`);
return super.getTable(name);
}
decompress() {
// "Decompress" the data.
return this.data + this.data;
}
}It is crucial that the data gets decompressed only once. The Flag in line 3 ensures that. It is initialised to false. When the data is needed for the first time, the flag is checked, the data gets eventually decoded, and then the flag gets set to true to prevent a second decompression. Decompression is emulated by simply doubling the input data.
Create a second driver script run-woff2.js:
// run-woff2.js
import { WOFF2Font } from './woff2-font.js';
console.log('creating font');
const font = new WOFF2Font('abcxyz');
console.log('font instantiated');
console.log(font.getTable('hmtx'));And run node ./run-woff2.js:
creating font
decompressed flag in constructor: undefined
decompress flag before: undefined
decompress flag after: true
table data: abcxyzabcxyz
font instantiated
decompress flag before: false
decompress flag after: true
table data: abcxyzabcxyzabcxyzabcxyzThe first time that the table is accessed from inside the constructor, everything looks correct. The WOFF2Font "decompresses" the data by doubling it.
But the guard against double decompression does not work. When the instance is used, the table data is "decompressed" again.
Explanation
Look closely at the output of run-woff2.js, especially the lines that print the value of the flag:
...
decompressed flag in constructor: undefined
...The flag in the parent class constructor is undefined although it is initialised to false. You may say that this does not matter, because undefined is a falsy value. And rightly, the first time everything works as expected.
But the second time around, when the getTable() method is called from the instantiating code, you see this output:
...
decompress flag before: false
decompress flag after: true
table data: abcxyzabcxyzabcxyzabcxyzSuddenly, the flag is false, although it was true before.
This is how JavaScript is supposed to work. The order of execution without a subclass constructor is:
- Run the parent class constructor.
- Execute the field class initialisers in the subclass.
When the parent class constructor runs, there is no property decompressed. It is created later, after the constructor has returned, and then set to false. And this happens, although the property already exists and has a value of true.
How to Avoid?
There is no easy fix to the problem in plain JavaScript. The best rule to follow is to never use class field initialisers in subclasses but that leads to ugly code like this:
export class WOFF2Font extends SFNTFont {
constructor(data) {
super(data);
this.decompressed = this.decompressed ?? false;
}
// ...
}You can also not initialise:
export class WOFF2Font extends SFNTFont {
decompressed;
// ...
}But that is ugly and confusing, because not initialising a boolean flag is bad style.
Normally, the issue is a strong indicator of an architectural flaw, and you should consider refactoring your code.
You may think that another fix would be to decompress the input data unconditionally in the subclass constructor:
export class WOFF2Font extends SFNTFont {
constructor(data) {
this.decompress(); // Does not work!
super(this.data);
}
// ...
}But you cannot access this in the constructor before calling super().
In TypeScript, you seemingly have a proper fix:
export class WOFF2Font extends SFNTFont {
private decompressed!: boolean;
// ...
}This works because the non-null assertion does nothing. It just tells the TypeScript compiler, "I know what I'm doing". When you look at the transpiled output, you see that the property is simply not initialised.
My preferred approach is to move the initialisation into the constructor, where it belongs. The current lazy loading approach does not offer any real advantage here, because the class is useless without decompressing the input data. Here is the resulting version:
// woff2-font.js
import { SFNTFont } from './sfnt-font.js';
export class WOFF2Font extends SFNTFont {
decompressed;
constructor(data) {
super(data);
this.decompress();
this.decompressed = true;
}
getTable(name) {
if (!this.decompressed) {
throw new Error('Attempt to use uninitialised object!');
}
return super.getTable(name);
}
decompress() {
return this.data + this.data;
}
}If you have better ideas, please leave a comment!
Leave a comment
Giving your email address is optional. But please keep in mind that you cannot get a notification about a response without a valid email address. The address will not be displayed with the comment!