Writing a Toolbox Module¶
What a module is¶
A module is a self-contained feature unit. The module system provides three things:
Settings — persistent user preferences stored in
browser.storage.local, surfaced in the Toolbox config UILifecycle — an
init()function called when the module activates, expected to return a cleanup functionRegistration — all modules are listed in
init.ts; the system callsinit()andcleanup()as needed
Your job when writing a module is to express three concerns in their correct layer:
Layer |
File |
Responsibility |
|---|---|---|
Config |
|
Declare what settings exist and their defaults |
Data |
|
Domain types, wiki read/write, schema upgrades |
Behavior |
|
Handler logic, grouped into factory functions |
UI |
|
React components; purely presentational |
Wiring |
|
Create factories, attach lifecycle handlers, nothing else |
Deciding your file layout¶
Start with the minimal set and add files when a real concern arises.
Every module needs:
index.ts— alwayssettings.ts— always (even if the array is empty)
Add schema.ts when the module has domain data structures that exist independently of settings: wiki-stored config shapes, API response types, multi-valued data models, or any type shared between api.ts and components/. Skip it if the module only reads/writes typed settings with no external storage.
Add moduleapi.ts when the module reads from or writes to a subreddit wiki page or performs schema upgrades on stored data.
Add dom.ts when the module does anything in the page — injects UI, responds to events, observes the DOM. Almost every module has this.
Add components/ for React UI: popups, overlays, panels. Skip for modules that inject only plain DOM or do no UI at all.
Add store.ts only when multiple factories and/or React components all need to subscribe to the same mutable state (e.g. a pub/sub counter). This is rare.
Structural variants¶
Multi-feature modules — when a module’s behavior is too large or too distinct to live in a single dom.ts, use a features/ subdirectory instead. Each file in features/ exports one create*Handlers() factory; index.ts calls the factories and wires the returned handlers. The strongest case for splitting is independently toggle-able behaviors (each guarded by its own boolean setting), but features/ is also correct whenever a sub-behavior is logically separate enough to warrant its own file and test. Do not leave extra behavior files at the module root — if it isn’t dom.ts, schema.ts, moduleapi.ts, store.ts, settings.ts, or index.ts, it belongs in features/ (or components/ if it is a React component). See betterbuttons (toggle-able) and modbar (mixed).
Cross-platform modules — if old Reddit and new Reddit (shreddit) need separate implementations, add a platformInterface.ts file that declares a platform-agnostic TypeScript interface for all DOM operations the module needs, and exports a factory (createOldReddit*()) that returns a concrete binding against dom/oldReddit/ helpers. Feature handler factories in dom.ts (or features/) accept the interface as an argument and stay platform-neutral. index.ts picks the right factory at runtime via isOldReddit. Use oldReddit/ and shreddit/ subdirectories when a feature also requires platform-specific React components or sizeable non-DOM logic that doesn’t belong in platformInterface.ts. See comment and commenttriage.
File reference¶
settings.ts¶
Declares every user-facing preference for the module.
import {defineSettings, InferSettings,} from '../../framework/module'
export const settings = defineSettings(
[
{
id: 'enableFoo',
type: 'boolean',
default: false,
description: 'Enable the foo behavior.',
},
{
id: 'fooLabel',
type: 'text',
default: 'Foo',
description: 'Label shown for foo.',
},
] as const,
)
export type MyModuleSettings = InferSettings<typeof settings>
Rules:
defineSettings([...] as const)— theas constis required for type inferenceExport
settings(the value) andtype MyModuleSettings(the inferred type); nothing elseThe only permitted additional import is
getSettingAsyncwhen ahiddencallback must read another setting’s value to determine visibility
schema.ts¶
Holds all domain interfaces — types that describe stored or transferred data, not UI props.
export const SCHEMA_VERSION = 6;
export const MIN_SCHEMA_VERSION = 4;
export interface NoteEntry {
n: string; // note text
t: number; // unix timestamp
m: string; // moderator username
l: string; // link (encoded)
w: number; // note type index
}
export interface NotesData {
ver: number;
users: Record<string, {ns: NoteEntry[]}>;
}
export const defaultNoteTypes = [ ... ] as const;
Rules:
Components import domain types from
../schema, never from each otherStatic lookup tables and defaults live here alongside their interfaces
No imports from
dom.ts,moduleapi.ts, orcomponents/
moduleapi.ts¶
Reads and writes external storage (subreddit wikis, extension storage). Contains any schema upgrade/migration logic.
import {postToWiki, readFromWiki,} from '../../api/resources/wiki'
import {NotesData, SCHEMA_VERSION,} from './schema'
export async function getNotes (subreddit: string,): Promise<NotesData | null> {
const result = await readFromWiki<NotesData>(subreddit, 'usernotes', true,)
if (!result.ok) { return null } // result.reason: 'no_page' | 'invalid_json' | 'unknown_error'
return inflate(result.data,) // decompress/upgrade as needed
}
export async function saveNotes (
subreddit: string,
data: NotesData,
): Promise<void> {
await postToWiki(
subreddit,
'usernotes',
deflate(data,),
'toolbox usernotes',
false,
false,
)
}
Rules:
No module-level mutable state; all state flows through function arguments and return values
Return types are typed against
schema.tsinterfaces, notanyNo event listeners, no lifecycle wiring
dom.ts¶
Contains the handler logic for page behavior. Exports one or more factory functions — each factory closes over settings and returns a handler bundle: a plain object mapping handler names to functions.
import {MyModuleSettings,} from './settings'
export interface MyHandlers {
handleClick: (element: Element, event: MouseEvent,) => void
handleNewPage: (event: CustomEvent,) => void
/** Disposes everything this factory registered; `index.ts` passes it to `lifecycle.mount`. */
cleanup: () => Promise<void>
}
export function createMyHandlers (s: MyModuleSettings,): MyHandlers {
const seen = new Set<string>()
const scope = createLifecycle()
scope.mount(
renderAtLocation('authorActions', {id: 'mymodule.author',}, renderTag,),
)
return {
cleanup: scope.cleanup,
handleClick (element, _event,) {
const id = element.getAttribute('data-fullname',) ?? ''
if (seen.has(id,)) { return } // domain state lives in the factory closure
seen.add(id,)
scope.timeout(() => {/* ... */}, 200,)
},
handleNewPage (_event,) {
seen.clear()
},
}
}
Rules:
Factory functions are named
create*Handlers()— alwaysFactories must not accept a
Lifecycleinstance as an argument. A factory that needs to register cleanup creates its own disposal scope withcreateLifecycle()and returnsscope.cleanup;index.tsmounts it vialifecycle.mount(handlers.cleanup).A factory uses its own scope only for the disposables it owns (renderers, internal timers/observers). Wiring the handlers it returns (
lifecycle.on/delegate) stays inindex.ts.All domain state lives inside the factory closure, not at module scope
components/¶
React components are purely presentational: props in, callbacks out, no direct API calls.
// components/MyPopup.tsx
interface Props {
note: NoteEntry; // domain type from ../schema
onSave: (n: NoteEntry) => void;
onClose: () => void;
}
export function MyPopup ({note, onSave, onClose}: Props) { ... }
Rules:
Domain types come from
../schema, not from sibling component filesComponents may use
useEffect+addEventListenerfor events scoped to the component’s own mount/unmount lifetimeCo-locate CSS modules (
.module.css) alongside the component file
index.ts¶
The entry point. Its only job is to instantiate the Module, then in init(): create the lifecycle, call factories, attach handlers to the lifecycle, return cleanup.
import {createLifecycle,} from '../../framework/lifecycle'
import {Module,} from '../../framework/module'
import {isCommentsPage,} from '../../util/reddit/pageContext'
import {createMyHandlers,} from './dom'
import {MyModuleSettings, settings,} from './settings'
export default new Module<MyModuleSettings>({
name: 'My Module',
id: 'MyModule',
enabledByDefault: true,
oldReddit: true,
settings,
}, function init (s,) {
if (!isCommentsPage) { return }
const lifecycle = createLifecycle()
const handlers = createMyHandlers(s,)
lifecycle.mount(handlers.cleanup,)
lifecycle.on(window, 'TBNewPage', handlers.handleNewPage,)
lifecycle.delegate<MouseEvent>(
document.body,
'click',
'.my-selector',
handlers.handleClick,
)
return lifecycle.cleanup
},)
Rules:
index.tscontains only: imports, theModuleconstructor,createLifecycle(), factory calls,lifecycle.*wiring calls, andreturn lifecycle.cleanupNo helper function definitions, no inline lambdas with business logic, no DOM queries, no state initialization
Platform restrictions belong on the
Moduleoptions (oldReddit: true/shreddit: true)return lifecycle.cleanup— return the function reference, do not call it
Lifecycle wiring¶
The Lifecycle object manages everything that needs cleanup when the module re-initializes or is disabled.
Method |
Use for |
|---|---|
|
DOM event listeners on |
|
Event delegation — fires handler when a matching descendant is the event target |
|
MutationObserver — creates, starts observing, and disconnects on cleanup |
|
|
|
|
|
Injected DOM elements that should be removed on cleanup |
|
Any other cleanup function — runs in reverse registration order |
Never use raw addEventListener, setInterval, setTimeout, or new MutationObserver directly — always go through a lifecycle.
Type discipline¶
anyis permitted only at genuine external boundaries: raw Reddit API JSON, extension storage readsUse
unknown+ narrowing (instanceof Error) incatchblocksFor
fetch/TBApi.getJSONresults:anyat the immediate response boundary is acceptable; don’t carryanydeeper into business logicDomain types flow from
schema.ts→api.ts/dom.ts/components/; never in reverse
Pre-PR checklist¶
npm testpassesnpm run buildpasses for Chrome and Firefoxindex.tscontains no helper function definitions, inline business logic, DOM queries, or state initializationdom.tsfactory functions contain nolifecycle.on/lifecycle.observecallsAll domain interfaces live in
schema.ts, not inline in component filesNo sibling-component type imports (e.g. importing a type from
./AddUserNotePopupinstead of../schema)All settings used in
dom.tsare declared insettings.tsand destructured from the settings argument; no unused setting declarationsNo raw
addEventListener,setInterval,setTimeout, ornew MutationObserver— all through lifecycleInjected DOM elements are removed in cleanup
getAttribute/datasetresults guarded before useNo redundant conditions: simplify
A || (!A && B)toA || BNo always-truthy guards on non-nullable types
parseIntalways called with explicit radix