Skip to content

RegisterAsSubcommandGroup evaluates the localized builder in the piece constructor, before translations are resolvable #82

Description

@RedStar071

Versions

  • @wolfstar/plugin-subcommands-advanced@2.0.3
  • @wolfstar/plugin-i18next@2.0.2
  • @wolfstar/http-framework@3.6.0
  • @discordjs/builders@1.14.1
  • @sapphire/shapeshift@4.0.0
  • Node 24.21.0

Symptom

In a consumer bot, every subcommand registered with @RegisterAsSubcommandGroup whose builder uses applyLocalizedBuilder (from plugin-i18next) fails to load. The store logs, for each one:

Error when loading '/app/dist/commands/subscriptions/twitch/add.mjs':
ExpectedConstraintError > s.string().regex()
  Received: 'addName'
  at applyNameLocalizedBuilder (@wolfstar/plugin-i18next)
  at applyLocalizedBuilder (@wolfstar/plugin-i18next)
  at parseSlashSubcommand (plugin-subcommands-advanced/src/lib/utils/functions.ts:30:12)

This is not fatal — the client still reaches Ready — but in the case that surfaced this, 5 of 7 subcommands across the affected parent commands failed to load, so the bot starts with a chunk of its command tree missing.

What was already ruled out on the consumer side: the translation keys and files are correct. The commands/twitch namespace exists for every locale the bot loads, with every key (addName, addDescription, …), and it resolves correctly as soon as client.load() has finished. t("commands/twitch:addName") called during piece construction returns the raw, unresolved key; the exact same call after client.load() returns "add" with the same locale.

Root cause: confirmed mechanism

Checked directly against the packages' source (wolfstar-project/plugins@db9e75a for the two plugins, wolfstar-project/stars-components@cde63d4 for http-framework — both match the reported versions exactly, no drift on main).

1. parseSlashSubcommand invokes a function-form builder immediately, synchronously, when called:

export function parseSlashSubcommand(
subcommand: SlashSubcommandResolvable,
): SlashCommandSubcommandBuilder {
if (typeof subcommand === "function") {
const builder = new SlashCommandSubcommandBuilder();
return subcommand(builder, container) ?? builder;
}

export function parseSlashSubcommand(
  subcommand: SlashSubcommandResolvable,
): SlashCommandSubcommandBuilder {
  if (typeof subcommand === "function") {
    const builder = new SlashCommandSubcommandBuilder();
    return subcommand(builder, container) ?? builder;
  }
  ...

2. RegisterAsSubcommandGroup calls parseSlashSubcommand inside the decorated class's constructor, before super() runs — i.e. while the piece is being constructed by the store loader:

return function decorate<T extends CommandConstructor>(target: T): T {
const decorated = class extends target {
public constructor(...args: any[]) {
const [context, baseOptions = {}] = args as [
FrameworkCommand.LoaderContext,
FrameworkCommand.Options,
];
const parsed = parseSlashSubcommand(slashSubcommand);
const name =
container.client?.options.subcommandsAdvanced?.nameCommandsAutogenerated === true
? `${parentCommandName}/${groupName}/${parsed.name}`
: baseOptions.name;
super(context, { ...baseOptions, name });
analyzeSubcommandGroupParsed(this, parentCommandName, groupName, slashSubcommand);
}
} as T;

const decorated = class extends target {
  public constructor(...args: any[]) {
    const [context, baseOptions = {}] = args as [...];
    const parsed = parseSlashSubcommand(slashSubcommand); // <-- builder callback runs here
    const name =
      container.client?.options.subcommandsAdvanced?.nameCommandsAutogenerated === true
        ? `${parentCommandName}/${groupName}/${parsed.name}`
        : baseOptions.name;

    super(context, { ...baseOptions, name });
    analyzeSubcommandGroupParsed(this, parentCommandName, groupName, slashSubcommand);
  }
};

Note this isn't arbitrary — parsed.name is needed synchronously to build the autogenerated piece name passed to super() a few lines later when subcommandsAdvanced.nameCommandsAutogenerated is on (as it is in the reproducing config), so the eager call looks intentional rather than an oversight. RegisterAsSubcommand has the identical pattern.

3. By contrast, @RegisterCommand (from http-framework itself) never calls the builder at decoration or construction time — it only stores the raw function, and the function is invoked lazily, later, from toJSON():

https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/decorators/RegisterCommand.ts#L26-L30

export function RegisterCommand<Options extends Command.Options = Command.Options>(data: ChatInputCommandResolver.CommandData) {
	return createClassDecorator(function decorate(target: typeof Command<Options>) {
		ensureChatInputCommandResolver(target).setCommand(data);
	});
}

setCommand just stores the reference:

https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/resolvers/ChatInputCommandResolver.ts#L31-L34

and the function form is only actually called from #normalizeCommand, itself only reachable through #resolve()toJSON():

https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/resolvers/ChatInputCommandResolver.ts#L66-L81

public toJSON(): ChatInputCommandResolver.ResolvedCommand {
  return (this.#data ??= this.#resolve());
}

#resolve(): ChatInputCommandResolver.ResolvedCommand {
  const command = this.#normalizeCommand(this.#commandData);
  ...

This is presumably called at command-sync/registration time, well after the whole store has finished loading — which is exactly why the parent command and /info (both using @RegisterCommand with applyLocalizedBuilder) load fine while subcommands registered through plugin-subcommands-advanced don't.

4. applyNameLocalizedBuilder / applyDescriptionLocalizedBuilder resolve translations synchronously the instant they're called, via getLocalizedDatacontainer.i18n's already-loaded languages map:

export function applyNameLocalizedBuilder<
T extends BuilderWithName,
const TOpt extends TOptions = TOptions,
Ns extends Namespace = AnyNamespace,
KPrefix = undefined,
>(builder: T, key: ParseKeys<Ns, TOpt, KPrefix>) {
const result = getLocalizedData(key);
return builder.setName(result.value).setNameLocalizations(result.localizations);
}

So: whenever parseSlashSubcommand runs a function-form builder that calls applyLocalizedBuilder, container.i18n needs to already be fully initialized at that exact moment. For RegisterAsSubcommandGroup that moment is piece construction time, during store loading.

Open question — not yet confirmed, needs investigation

This is the part I could not pin down from the source, and it's more surprising than it first looks: Client#load() fully awaits every registered PreLoad hook — including plugin-i18next's await container.i18n.init() — before it ever calls container.stores.load():

https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/Client.ts#L142-L153

public async load(options: LoadOptions = {}) {
  for (const plugin of Client.plugins.values(PluginHook.PreLoad)) {
    await plugin.hook.call(this, this.options);
    this.emit(Events.PluginLoaded, plugin.type, plugin.name);
  }
  ...
  await container.stores.load();
  ...

and plugin-i18next's preLoad hook is exactly await container.i18n.init():

public static async [preLoad](this: Client): Promise<void> {
await container.i18n.init();
}

InternationalizationHandler#init calls i18next.init() with initImmediate: false, load: "all", ns: <every discovered namespace>, preload: <every discovered language>:

public async init() {
const { namespaces, languages } = await this.walkRootDirectory(this.languagesDirectory);
const userOptions = isFunction(this.options.i18next)
? this.options.i18next(namespaces, languages)
: this.options.i18next;
const ignoreJSONStructure = userOptions?.ignoreJSONStructure ?? false;
const skipOnVariables = userOptions?.interpolation?.skipOnVariables ?? false;
i18next.use(Backend);
await i18next.init({
backend: this.backendOptions,
fallbackLng: this.options.defaultName ?? "en-US",
initImmediate: false,
interpolation: {
escapeValue: false,
...userOptions?.interpolation,
skipOnVariables,
},
load: "all",
defaultNS: this.options.defaultNS ?? "default",
ns: namespaces,
preload: languages,
...userOptions,
ignoreJSONStructure,
});
this.namespaces = new Set(namespaces);
for (const item of languages) {
this.languages.set(item, i18next.getFixedT(item));
}
this.languagesLoaded = true;

and @wolfstar/i18next-backend's read() does correctly wait on its async readPaths() before invoking the callback when initImmediate is falsy:

https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/i18next-backend/src/index.ts#L16-L30

So on paper, by the time container.stores.load() — and therefore any piece constructor — runs, i18next.init() should have already resolved for every namespace/language pair, including commands/twitch. That contradicts the observed symptom, and I don't have an explanation for the gap. Things worth checking that are outside what I verified here:

  • Whether i18next.init()'s returned promise genuinely only resolves once every ns×preload combination has been read by the backend, or whether it can resolve early in some configuration (e.g. only for the default namespace) while other namespaces finish loading asynchronously afterward.
  • Whether plugin-subcommands-advanced's custom SubcommandsAdvancedLoaderStrategy — which replaces the CommandStore's loader strategy from a postInitialization hook that runs synchronously inside the Client constructor, i.e. before load()/PreLoad even starts — changes how or when pieces under it get constructed relative to the default @sapphire/pieces strategy.
  • Whether container.stores.load()'s own file-discovery/import step can start constructing pieces before the preceding await in Client#load() has actually settled, in some interleaving that isn't obvious from reading the two functions in isolation.

I'm flagging this as an open question rather than asserting a cause, since I could not reproduce it directly — only trace the mechanism through the source.

Suggested minimal repro

A parent command with @RegisterCommand + a child with @RegisterAsSubcommandGroup, both using applyLocalizedBuilder, with subcommandsAdvanced: { nameCommandsAutogenerated: true } set on the client options. If useful, I can help put this together as an actual runnable repro.

Possible direction (not prescriptive)

Given @RegisterCommand already demonstrates a fully-lazy pattern (store the raw builder, resolve it only at toJSON() time), having RegisterAsSubcommandGroup/RegisterAsSubcommand defer parseSlashSubcommand the same way — resolving the name lazily too, or resolving it earlier via a dedicated pre-store i18n-ready hook — would probably sidestep this whole class of timing issue regardless of what the exact race turns out to be. That said, the eager call exists for a reason (the synchronously-needed name for super()), so there may be constraints I'm not seeing that ruled this out already.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions