Skip to content

fix(hooks): use globalThis singleton for handler registry to survive bundle splitting#32292

Merged
steipete merged 3 commits intoopenclaw:mainfrom
Drickon:fix/internal-hooks-bundle-split
Mar 3, 2026
Merged

fix(hooks): use globalThis singleton for handler registry to survive bundle splitting#32292
steipete merged 3 commits intoopenclaw:mainfrom
Drickon:fix/internal-hooks-bundle-split

Conversation

@Drickon
Copy link
Contributor

@Drickon Drickon commented Mar 2, 2026

Problem

After PR #9859 landed, hooks registered via registerInternalHook were silently never firing in production. The bundler emits internal-hooks into multiple chunks — the loader registers handlers into one chunk's Map, while triggerInternalHook in get-reply.ts reads from a different chunk's Map. The handler list is empty; hooks run zero times.

Reproduce: enable any external hook via hooks.external.entries that listens on message:transcribed. Send a voice message. The handler is registered at startup but never invoked.

Fix

Use globalThis.__openclaw_internal_hook_handlers__ as a shared singleton. All module copies check for and reuse the same Map on first access, so registrations from one chunk are always visible to triggers in another.

Tests

All 29 existing internal-hooks.test.ts tests pass. pnpm check clean.

Note: a unit test cannot reproduce the bundle-split scenario (tests run in a single module context). The fix was validated end-to-end by confirming external hooks fire after the change.

Follows #9859.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR fixes a silent hook-firing regression introduced by bundle splitting: because the bundler could emit internal-hooks into multiple chunks, registerInternalHook and triggerInternalHook were each operating on their own private Map instance, so registered handlers were never found at trigger time. The fix is to store the handler registry on globalThis.__openclaw_internal_hook_handlers__ and lazily initialise it only when the property is absent, so every chunk that imports this module shares the same Map.

Key observations:

  • The approach (globalThis singleton with a namespaced key) is the standard, widely-used solution for exactly this class of bundler-deduplication problem.
  • Existing helpers — clearInternalHooks, unregisterInternalHook, getRegisteredEventKeys — all operate on the shared Map reference and continue to work correctly.
  • The handlers local variable is assigned without a non-null assertion (!) after the initialisation guard. TypeScript does not narrow mutable object properties through control-flow in the same way it narrows locals, so under strictNullChecks this may yield a Map | undefined type and downstream type errors. Adding ! or using ??= would make the intent unambiguous (see inline comment).
  • Because globalThis persists for the process lifetime, handlers registered during module evaluation survive HMR reloads in development. This means handlers registered by a stale module version may still fire after a hot reload — a minor development-only concern that is an acceptable trade-off for the production correctness fix.

Confidence Score: 4/5

  • This PR is safe to merge; it correctly fixes a real production regression with a well-established pattern and no logic changes to the hook dispatch code.
  • The fix is minimal and targeted, and the globalThis singleton pattern is the correct solution for cross-chunk module deduplication. The only open concern is a missing non-null assertion that could surface as a TypeScript type error if null-check strictness is increased, but it does not affect runtime behaviour and pnpm check is currently clean.
  • No files require special attention beyond the minor type-safety note on src/hooks/internal-hooks.ts line 219.

Last reviewed commit: 3e8bd73

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

if (!_g.__openclaw_internal_hook_handlers__) {
_g.__openclaw_internal_hook_handlers__ = new Map<string, InternalHookHandler[]>();
}
const handlers = _g.__openclaw_internal_hook_handlers__;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing non-null assertion on handlers assignment

After the if initialization guard, TypeScript may still infer handlers as Map<string, InternalHookHandler[]> | undefined because control-flow narrowing does not generally apply to mutable object properties (TypeScript cannot guarantee the property wasn't modified between the if block and this line). Adding a ! non-null assertion makes the intent explicit and guards against type errors if strictNullChecks is tightened in the future.

Suggested change
const handlers = _g.__openclaw_internal_hook_handlers__;
const handlers = _g.__openclaw_internal_hook_handlers__!;

Alternatively, the three lines can be collapsed with the nullish assignment operator for clarity:

_g.__openclaw_internal_hook_handlers__ ??= new Map<string, InternalHookHandler[]>();
const handlers = _g.__openclaw_internal_hook_handlers__;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/internal-hooks.ts
Line: 219

Comment:
**Missing non-null assertion on `handlers` assignment**

After the `if` initialization guard, TypeScript may still infer `handlers` as `Map<string, InternalHookHandler[]> | undefined` because control-flow narrowing does not generally apply to mutable object properties (TypeScript cannot guarantee the property wasn't modified between the `if` block and this line). Adding a `!` non-null assertion makes the intent explicit and guards against type errors if `strictNullChecks` is tightened in the future.

```suggestion
const handlers = _g.__openclaw_internal_hook_handlers__!;
```

Alternatively, the three lines can be collapsed with the nullish assignment operator for clarity:

```ts
_g.__openclaw_internal_hook_handlers__ ??= new Map<string, InternalHookHandler[]>();
const handlers = _g.__openclaw_internal_hook_handlers__;
```

How can I resolve this? If you propose a fix, please make it concise.

Drickon and others added 3 commits March 3, 2026 00:26
…bundle splitting

Without this fix, the bundler can emit multiple copies of internal-hooks
into separate chunks. registerInternalHook writes to one Map instance
while triggerInternalHook reads from another — resulting in hooks that
silently fire with zero handlers regardless of how many were registered.

Reproduce: load a hook via hooks.external.entries (loader reads one chunk),
then send a message:transcribed event (get-reply imports a different chunk).
The handler list is empty; the hook never runs.

Fix: use globalThis.__openclaw_internal_hook_handlers__ as a shared
singleton. All module copies check for and reuse the same Map, ensuring
registrations are always visible to triggers.
Addresses greptile review: collapses the if-guard + assignment into
a single ??= expression so TypeScript can narrow the type without
a non-null assertion.
@steipete steipete force-pushed the fix/internal-hooks-bundle-split branch from ee5ea80 to 8fee445 Compare March 3, 2026 00:27
@steipete steipete merged commit 68e982e into openclaw:main Mar 3, 2026
@steipete
Copy link
Contributor

steipete commented Mar 3, 2026

Landed via temp rebase onto main.

  • Gate: pnpm test -- src/hooks/internal-hooks.test.ts src/hooks/message-hooks.test.ts
  • Note: full pnpm check is currently red on unrelated main-branch failures in extensions/tlon and src/discord, src/media-understanding, src/security type checks.
  • Land commit: 8fee445
  • Merge commit: 68e982e

Thanks @Drickon!

dawi369 pushed a commit to dawi369/davis that referenced this pull request Mar 3, 2026
OWALabuy pushed a commit to kcinzgg/openclaw that referenced this pull request Mar 4, 2026
AytuncYildizli pushed a commit to AytuncYildizli/openclaw that referenced this pull request Mar 4, 2026
zooqueen pushed a commit to hanzoai/bot that referenced this pull request Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants