Firefox Addons and EventTarget
At least it's not a polyfill
New dog, old tricks
So, that 68-million cycle machine I verified recently?
Part of why I didn't give up partway through (after finding a further deoptimization after 3 days of runtime) was that that was the perfect motivation for me to get used to my shiny new Lenovo. I'm a creature of habit, okay? If I didn't take drastic measures by tying down my Macbook for three weeks I would probably have kept using it until it died at an inconvenient time.
Now I have two functional machines!
I was also very happy
to have the ppportunity do things right the first time this time around,
i.e.
always using venvs when working with Python,
setting my default browser to Librewolf,
installing oh-my-zsh and keeping the bashrc clean,
and just generally minimizing the number of installs I lose track of.
Since Librewolf is a Firefox variant, this also seemed like a good time to make sure my pet extension (Cellulart) actually worked on Firefox. Because, well, I had been trusting that testing it on one browser was enough, and that Firefox would behave identically to Chrome in all the ways that mattered to my unsophisticated little project.
![TypeError: this.socket.post is not a function [Learn More]](/posts/eventtarget/wxUAZUPiHM-793.jpeg)
Huh.
So I was wrong.
Where's my post?
This Gecko-exclusive error was thrown by
something trying to call post on an instance of Socket,
the thing that handles communication
between Cellulart's content scripts and injected scripts:
class Socket extends EventListening(EventTarget) {
constructor(globalGame: BaseGame) {
super();
// ... attaching event listeners
}
// ... onroundleave(), onlobbyleave()
public post(purp: string, data?: any) {
Console.log(`outgoing (${purp}, ${data})`, "Socket");
window.postMessage(
{
direction: "toSocket",
purpose: purp,
data: data,
},
"https://garticphone.com",
);
}
}
A quick console.log(this) revealed that on instantiation,
a Socket's class was listed as EventTarget.
So not only was Socket.post getting lost,
the instantiated object didn't have
the EventListening mixin
either:
class None {}
export function EventListening<TBase extends new (...args: any[]) => {}>(
BaseClass: TBase = None as TBase,
) {
return class extends BaseClass implements EventListenerObject {
handleEvent(event: Event): void {
const fnName = "on" + event.type;
if (fnName in this) {
(this as unknown as Record<string, (event: Event) => void>)[fnName](
event,
);
} else {
console.warn(`EventListenerObject has no method on${event.type}`);
}
}
};
}
which meant one of a few things,
in increasing order of distance from the Socket itself:
EventListening()was instantiated in a way that itself and everything on top of it was being ignoredEventTargetwas instantiated in a way that ignores everything on top of it- The
Socketconstructor was overwritten or incorrectly imported - TypeScript was being silly
After immediately eliminating TypeScript as a possible cause on account of everything working fine in Chrome, what was left?
Who's responsible?
Thing is,
none of EventListening(), EventTarget, and Socket looked like the problem.
EventListening() was used in other places.
Most critically, EventListening(ModuleLike) formed the skeleton of
EVERY module in Cellulart,
meaning that if EventListening() was misbehaving,
none of the modules should be able to respond to game events.
I ruled this out by checking the functioning of Koss, a tool that moves elements within the DOM with every phase transition event.
EventTarget didn't look like the problem either,
according to a quick test in the browser's Inspector
that verified that subclassing EventTarget worked fine:
class A extends EventTarget {
foo() {
return "bar";
}
}
const a = new A();
console.log(a.foo()); // "bar"
Finally, console.log(Socket) and console.log(this.constructor)
showed that the Socket constructor was indeed being correctly imported and called.
Hm.
Well... maybe I ought to check
that EventTarget is properly subclassed
in other parts of my TypeScript code as well.
export class StrokeBuffer extends EventTarget {
private queue: StrokeData[] = [];
constructor(private abortController: AbortController) {
super();
console.log("Assembled a StrokeBuffer:");
console.log(this); // printed an EventTarget. Uh oh
}
// ...
}
export class BaseGame extends EventTarget {
// a long list of public properties...
constructor() {
super();
console.log("Assembled a BaseGame:");
console.log(this); // printed an EventTarget. Uh oh!
}
}


What on earth?
Now I need to ask what the difference is between transpiled, packaged add-on code and the A code I wrote in the browser's console. Well, we have three candidates:
- Transpiled.
- Packaged.
- Add-on.
Okay. What should we look at first?
First, I'll transpile a typed version of the A code from earlier:
class A extends EventTarget {
foo(): string {
return "bar";
}
}
const a: A = new A();
console.log(a.foo()); // "bar"
class A extends EventTarget {
foo() {
return "bar";
}
}
const a = new A();
console.log(a.foo()); // "bar"
That shouldn't be the problem then,
because the typed A code just transpiles to the untyped A code
that we've already seen works fine.
Maybe it's Webpack that's causing this issue?
I'll paste the typed A code into a content.ts file and pack it:
const path = require("path");
module.exports = (env) => {
return {
mode: "production",
target: "web",
entry: {
content: "./src2/content.ts",
},
output: {
path: path.resolve(__dirname, "dist2"),
filename: "[name].js",
clean: true,
},
resolve: {
extensions: [".ts"],
},
devtool: false, // I forgot what this does, to be honest
module: {
rules: [
{
test: /.(ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-typescript"],
},
},
},
],
},
};
};
Pasting the code produced by this in dist2/content.js
directly into the browser console
does indeed print "bar".
I did also check that packing the untyped A code worked as well,
just to be sure.
Webpack basically just wrapped the A code in a (() => { ... })(),
so that's not going to cause any errors.
All of that being the case,
fingers crossed the problem is being in an add-on,
or I'm really going to lose my mind.
Let's throw our untyped A code into the content.js of a new unpacked add-on:
{
"manifest_version": 3,
"name": "EventTarget test",
"version": "0.0.1",
"content_scripts": [
{
"matches": ["https://garticphone.com/*"],
"js": ["content.js"]
}
],
"browser_specific_settings": {
"gecko": {
"id": "{9c773d26-5b2c-4c3e-ad36-a5a98e2d1d0f}"
}
}
}
![TypeError: a.foo is not a function [Learn More]](/posts/eventtarget/PfiwNyFo-o-657.jpeg)
Caught in 4k.
Why wasn't I told?
You know what's great fun? Asking LLMs questions that they don't know the answer to and watching them make stuff up. (Let me emphasize: LLMs are best used recreationally, and this particular mode of entertainment only works if they actually answer incorrectly.)
I asked Claude Sonnet 4.5 what it would do when confronted with the original problem,
I'm pasting in some transpiled Typescript add-on code. Suggest some avenues for investigation as to why the line console.log(this) prints a Socket to the console in Chrome, but an EventTarget to the console in Firefox.
and it proceeded to blame Babel's callSuper,
my EventListening(),
and the placement of my console.log(this), in that order.
Here's another thing I like doing. By asking the LLM a question you already know the answer to, you get a sense for how easily accessible / findable the answer to that question is in the LLM's training data. In other words, you find out roughly how hard it would be for someone else having the same issue to "just Google it".
Sure enough, putting "firefox addon eventtarget subclass not working" in Google
shows us nothing relevant.
"firefox addon extending native classes not working" gets us closer,
but we didn't have reason to believe that our EventTarget problems
extended to all native classes.
What ended up working for me was:
"firefox content script native class inheritance"
(Yes, this only happens in content scripts -
I checked by putting our A code in a background script.)
This search pulls up this bug report for the userscript loader Violentmonkey, which links to this bugzilla report. Two years old, no progress in the last year, labelled as a defect (so not intended behaviour), no priority, severity S3.
What's severity S3 mean?
Severity S3 (Normal) Blocks non-critical functionality or a work around exists
Oh, right. This post is supposed to be about fixing my code. Let's figure out that workaround, then!
How do we fix this?
Compared to identifying the problem, writing down the solution is basically a footnote. The most straightforward solution is to introduce something like a polyfill:
export let _EventTarget = patchEventTarget();
function patchEventTarget(): new (...args: any[]) => EventTarget {
class EventTargetSubclass extends EventTarget {}
let eventTargetSubclassable =
new EventTargetSubclass().constructor === EventTargetSubclass;
if (eventTargetSubclassable) {
return EventTarget;
}
console.warn(
"Gartic Cellulart: EventTarget is not subclassable. Using wrapper constructor.",
);
return class implements EventTarget {
private _target: EventTarget = new EventTarget();
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
this._target.addEventListener(type, callback, options);
}
dispatchEvent(event: Event): boolean {
return this._target.dispatchEvent(event);
}
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void {
this._target.removeEventListener(type, callback, options);
}
};
}
and then import our new _EventTarget
in every script that previously used EventTarget.
I think
return function () {
return new Proxy(new EventTarget(), {});
};
looks a lot cleaner, but it doesn't typecheck, unfortunately.
And by that I mean Firefox refuses to call addEventListener and dispatchEvent
on the Proxy.
Now I owe you a conclusion to this story, but I don't even know what the takeaways from all this are. This bug probably affects a double-digit number of developers at best, and the workaround is not hard to find.
Hey, but we had fun, right?
Oh, right.
Takeaway: I need to start testing major Cellulart builds on Firefox now.

