Skip to content

i18n-calypso: Replace EventEmitter with simplified subscriber callbacks #103304

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions client/components/calypso-i18n-provider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ const CalypsoI18nProvider: FunctionComponent< { i18n?: I18N; children?: React.Re
setLocaleSlug( i18n.getLocaleSlug() );
};

i18n.on( 'change', onChange );

return () => {
i18n.off( 'change', onChange );
};
return i18n.subscribe( onChange );
}, [ i18n ] );

useEffect( () => {
Expand Down
4 changes: 2 additions & 2 deletions client/components/community-translator/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ class CommunityTranslator extends Component {
// callback when translated component changes.
// the callback is overwritten by the translator on load/unload, so we're returning it within an anonymous function.
i18n.registerComponentUpdateHook( () => {} );
i18n.on( 'change', this.refresh );
this.i18nUnsubscribe = i18n.subscribe( this.refresh );
}

componentWillUnmount() {
i18n.off( 'change', this.refresh );
this.i18nUnsubscribe();
}

setLanguage() {
Expand Down
4 changes: 2 additions & 2 deletions client/layout/community-translator/launcher.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ class TranslatorLauncher extends Component {
highlightRef = createRef();

componentDidMount() {
i18n.on( 'change', this.onI18nChange );
this.i18nUnsubscribe = i18n.subscribe( this.onI18nChange );
window.addEventListener( 'keydown', this.handleKeyDown );
}

componentWillUnmount() {
i18n.off( 'change', this.onI18nChange );
this.i18nUnsubscribe();
window.removeEventListener( 'keydown', this.handleKeyDown );
}

Expand Down
2 changes: 1 addition & 1 deletion client/lib/i18n-utils/empathy-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import i18n, { I18N, translate } from 'i18n-calypso';
let defaultUntranslatedPlacehoder = translate( "I don't understand" );

// keep `defaultUntranslatedPlacehoder` in sync with i18n changes
i18n.on( 'change', () => {
i18n.subscribe( () => {
defaultUntranslatedPlacehoder = translate( "I don't understand" );
} );

Expand Down
2 changes: 1 addition & 1 deletion client/lib/translator-jumpstart/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,6 @@ export function trackTranslatorStatus( isTranslatorEnabled ) {
}

// re-initialize when new locale data is loaded
i18n.on( 'change', communityTranslatorJumpstart.init.bind( communityTranslatorJumpstart ) );
i18n.subscribe( communityTranslatorJumpstart.init.bind( communityTranslatorJumpstart ) );
Copy link
Member

Choose a reason for hiding this comment

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

The call of the init method, does it even work? The method takes two args, user and isUserSettingsReady. And returns early if user is null-ish. But the change event listener was not called with any arguments, is it? And neither is the new subscribe callback.

Copy link
Member Author

Choose a reason for hiding this comment

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

The call of the init method, does it even work? The method takes two args, user and isUserSettingsReady. And returns early if user is null-ish. But the change event listener was not called with any arguments, is it? And neither is the new subscribe callback.

I'll check on this tomorrow 👍

Copy link
Member Author

Choose a reason for hiding this comment

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

The call of the init method, does it even work? The method takes two args, user and isUserSettingsReady. And returns early if user is null-ish. But the change event listener was not called with any arguments, is it? And neither is the new subscribe callback.

I'll check on this tomorrow 👍

Update: You're correct that the initialization triggered by this subscriber always short-circuits at the user check. This subscriber is very old (originates in pre-OSS version), at which time the init function didn't take arguments. Additionally, I don't think we should rely on a side-effect like this when importing a module, since it isn't compatible with tree-shaking. That being said, the subscriber is being added.

Instead, I think the initialization is happening in the TranslatorLauncher component, which passes the user arguments correctly. It also appears to reinitialize many times when the component re-renders.

I'm not entirely familiar with how this should be working (e.g. in response to asynchronous loads or language changes), but I think it's fair to say that the subscriber is not doing anything in its current form and can be removed.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm going to keep this in as-is to keep it functionally equivalent, and will follow-up with a pull request proposing to remove the ineffective subscribe call.


export default communityTranslatorJumpstart;
6 changes: 6 additions & 0 deletions packages/i18n-calypso/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Trunk

- Breaking: Emitter object replaced with subscriber
- Before: `i18n.on( 'change', callback ); i18n.off( 'change', callback );`
- After: `const unsubscribe = i18n.subscribe( callback ); unsubscribe();`

## 7.5.0

- Add `translationOptions` to fixMe so it can work on translations with context.
Expand Down
1 change: 0 additions & 1 deletion packages/i18n-calypso/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"@tannin/sprintf": "^1.1.0",
"@wordpress/compose": "^7.23.0",
"debug": "^4.4.0",
"events": "^3.3.0",
"hash.js": "^1.1.7",
"lru": "^3.1.0",
"tannin": "^1.2.0",
Expand Down
26 changes: 9 additions & 17 deletions packages/i18n-calypso/src/i18n.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { EventEmitter } from 'events';
import interpolateComponents from '@automattic/interpolate-components';
import sprintf from '@tannin/sprintf';
import debugFactory from 'debug';
Expand Down Expand Up @@ -145,11 +144,7 @@ function I18N() {
};
this.componentUpdateHooks = [];
this.translateHooks = [];
this.stateObserver = new EventEmitter();
// Because the higher-order component can wrap a ton of React components,
// we need to bump the number of listeners to infinity and beyond
// FIXME: still valid?
this.stateObserver.setMaxListeners( 0 );
this.subscribers = new Set();
// default configuration
this.configure();
}
Expand Down Expand Up @@ -191,16 +186,13 @@ I18N.prototype.geolocateCurrencySymbol = async function ( callback ) {
callback?.( 'string' === typeof geoData?.country_short ? geoData.country_short : '' );
};

I18N.prototype.on = function ( ...args ) {
this.stateObserver.on( ...args );
I18N.prototype.subscribe = function ( callback ) {
this.subscribers.add( callback );
return () => this.subscribers.delete( callback );
};

I18N.prototype.off = function ( ...args ) {
this.stateObserver.off( ...args );
};

I18N.prototype.emit = function ( ...args ) {
this.stateObserver.emit( ...args );
I18N.prototype.emitChange = function () {
this.subscribers.forEach( ( callback ) => callback() );
};

/**
Expand Down Expand Up @@ -304,7 +296,7 @@ I18N.prototype.setLocale = function ( localeData ) {

this.state.tannin = new Tannin( { [ domain_key ]: this.state.locale } );

this.stateObserver.emit( 'change' );
this.emitChange();
};

I18N.prototype.getLocale = function () {
Expand Down Expand Up @@ -347,7 +339,7 @@ I18N.prototype.addTranslations = function ( localeData ) {
}
}

this.stateObserver.emit( 'change' );
this.emitChange();
};

/**
Expand Down Expand Up @@ -452,7 +444,7 @@ I18N.prototype.translate = function () {
*/
I18N.prototype.reRenderTranslations = function () {
debug( 'Re-rendering all translations due to external request' );
this.stateObserver.emit( 'change' );
this.emitChange();
};

I18N.prototype.registerComponentUpdateHook = function ( callback ) {
Expand Down
4 changes: 0 additions & 4 deletions packages/i18n-calypso/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ export const reRenderTranslations = i18n.reRenderTranslations.bind( i18n );
export const registerComponentUpdateHook = i18n.registerComponentUpdateHook.bind( i18n );
export const registerTranslateHook = i18n.registerTranslateHook.bind( i18n );
export const state = i18n.state;
export const stateObserver = i18n.stateObserver;
export const on = i18n.on.bind( i18n );
export const off = i18n.off.bind( i18n );
export const emit = i18n.emit.bind( i18n );
export const fixMe = i18n.fixMe.bind( i18n );

export type * from './types';
3 changes: 1 addition & 2 deletions packages/i18n-calypso/src/localize.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ export default function localize( ComposedComponent ) {
const [ counter, setCounter ] = useState( 0 );
useEffect( () => {
const onChange = () => setCounter( ( c ) => c + 1 );
i18n.on( 'change', onChange );
return () => i18n.off( 'change', onChange );
return i18n.subscribe( onChange );
}, [ i18n ] );

const i18nProps = useMemo( () => bindI18nProps( i18n, counter ), [ i18n, counter ] );
Expand Down
5 changes: 2 additions & 3 deletions packages/i18n-calypso/src/rtl.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ export function useRtl() {
return i18n.isRtl();
},
subscribe( callback ) {
i18n.on( 'change', callback );
return () => i18n.off( 'change', callback );
return i18n.subscribe( callback );
},
} ),
[ i18n ]
Expand All @@ -24,7 +23,7 @@ export function useRtl() {

export const withRtl = createHigherOrderComponent(
( WrappedComponent ) =>
forwardRef( ( props, ref ) => {
forwardRef( function WrappedRtlComponent( props, ref ) {
const isRtl = useRtl();
return <WrappedComponent { ...props } isRtl={ isRtl } ref={ ref } />;
} ),
Expand Down
76 changes: 76 additions & 0 deletions packages/i18n-calypso/src/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ describe( 'I18n', function () {
} );

describe( 'setLocale()', function () {
it( 'should emit a change event', () => {
const callback = jest.fn();
i18n.subscribe( callback );

i18n.setLocale();

expect( callback ).toHaveBeenCalled();
} );

describe( 'adding a new locale source from the same language', function () {
beforeEach( function () {
i18n.setLocale( {
Expand Down Expand Up @@ -191,13 +200,23 @@ describe( 'I18n', function () {

expect( translate( 'test-does-not-exist' ) ).toBe( 'translation3' );
} );

it( 'should return the new translation if it has been overwritten', function () {
i18n.addTranslations( {
'test-will-overwrite': [ 'not-translation1' ],
} );

expect( translate( 'test-will-overwrite' ) ).toBe( 'not-translation1' );
} );

it( 'should emit a change event', () => {
const callback = jest.fn();
i18n.subscribe( callback );

i18n.addTranslations( {} );

expect( callback ).toHaveBeenCalled();
} );
} );
} );

Expand Down Expand Up @@ -266,6 +285,63 @@ describe( 'I18n', function () {
} );
} );

describe( 'emitChange()', () => {
it( 'should call all subscribed callbacks', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();

i18n.subscribe( callback1 );
i18n.subscribe( callback2 );

i18n.emitChange();

expect( callback1 ).toHaveBeenCalled();
expect( callback2 ).toHaveBeenCalled();
} );

it( 'should not call unsubscribed callbacks', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
const callback3 = jest.fn();

i18n.subscribe( callback1 );
const unsubscribe2 = i18n.subscribe( callback2 );
i18n.subscribe( callback3 );

unsubscribe2();
i18n.emitChange();

expect( callback1 ).toHaveBeenCalled();
expect( callback2 ).not.toHaveBeenCalled();
expect( callback3 ).toHaveBeenCalled();
} );
} );

describe( 'reRenderTranslations()', () => {
it( 'should call subscriber callback', () => {
const callback = jest.fn();

i18n.subscribe( callback );

i18n.reRenderTranslations();

expect( callback ).toHaveBeenCalled();
} );
} );

describe( 'subscribe()', () => {
it( 'should return an unsubscribe function', () => {
const callback = jest.fn();

const unsubscribe = i18n.subscribe( callback );
unsubscribe();

i18n.setLocale();

expect( callback ).not.toHaveBeenCalled();
} );
} );

describe( 'hashed locale data', function () {
it( 'should find keys when looked up by simple hash', function () {
i18n.setLocale( {
Expand Down
7 changes: 1 addition & 6 deletions packages/i18n-calypso/src/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ export interface I18N {
registerTranslateHook( hook: TranslateHook ): void;
registerComponentUpdateHook( hook: ComponentUpdateHook ): void;

on( eventName: string, listener: EventListener ): void;
off( eventName: string, listener: EventListener ): void;
emit( eventName: string, ...payload: any ): void;
subscribe( callback: () => any ): () => void;

/**
* Returns `newCopy` if given `text` is translated or locale is English, otherwise returns the `oldCopy`.
Expand Down Expand Up @@ -180,9 +178,6 @@ export declare const isRtl: typeof i18n.isRtl;
export declare const defaultLocaleSlug: typeof i18n.defaultLocaleSlug;
export declare const registerTranslateHook: typeof i18n.registerTranslateHook;
export declare const registerComponentUpdateHook: typeof i18n.registerComponentUpdateHook;
export declare const on: typeof i18n.on;
export declare const off: typeof i18n.off;
export declare const emit: typeof i18n.emit;
export declare const fixMe: typeof i18n.fixMe;

export interface LocalizeProps {
Expand Down
3 changes: 1 addition & 2 deletions packages/i18n-calypso/src/use-translate.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ export default function useTranslate() {

useEffect( () => {
const onChange = () => setCounter( ( c ) => c + 1 );
i18n.on( 'change', onChange );
return () => i18n.off( 'change', onChange );
return i18n.subscribe( onChange );
}, [ i18n ] );

return useMemo( () => bindTranslate( i18n, counter ), [ i18n, counter ] );
Expand Down
1 change: 0 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21885,7 +21885,6 @@ __metadata:
"@types/react": "npm:^18.3.20"
"@wordpress/compose": "npm:^7.23.0"
debug: "npm:^4.4.0"
events: "npm:^3.3.0"
hash.js: "npm:^1.1.7"
lru: "npm:^3.1.0"
react: "npm:^18.3.1"
Expand Down