Skip to content

Commit

Permalink
fix: unicode chars in native environments + event emitter refactor (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
semoal committed Apr 9, 2021
1 parent 966f316 commit 39fa90d
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 48 deletions.
3 changes: 2 additions & 1 deletion packages/core/src/context.ts
Expand Up @@ -111,7 +111,8 @@ export function interpolate(
}

const result = formatMessage(translation)
if (typeof result === "string") return result.trim()
if (isString(result) && /\\u[a-fA-F0-9]{4}/g.test(result)) return JSON.parse(`"${result.trim()}"`)
if (isString(result)) return result.trim()
return result
}
}
4 changes: 2 additions & 2 deletions packages/core/src/eventEmitter.test.ts
Expand Up @@ -19,12 +19,12 @@ describe("@lingui/core/eventEmitter", () => {
const listener = jest.fn()
const emitter = new EventEmitter()

const unsubscribe = emitter.on("test", listener)
emitter.on("test", listener)
emitter.emit("test", 42)
expect(listener).toBeCalledWith(42)

listener.mockReset()
unsubscribe()
emitter.removeAllListeners()
emitter.emit("test", 42)
expect(listener).not.toBeCalled()
})
Expand Down
217 changes: 197 additions & 20 deletions packages/core/src/eventEmitter.ts
@@ -1,31 +1,208 @@
export class EventEmitter<
Events extends { [name: string]: (...args: any[]) => any }
> {
private readonly _events: {
[name in keyof Events]?: Array<Events[name]>
} = {}
type ListenerFunction = {
listener?: Function;
} & Function

on(event: keyof Events, listener: Events[typeof event]): () => void {
if (!this._hasEvent(event)) this._events[event] = []
export class EventEmitter {
static defaultMaxListeners: number = 10;
maxListeners: number | undefined;
events: Map<string | symbol, Function[]>;

this._events[event].push(listener)
return () => this.removeListener(event, listener)
constructor() {
this.events = new Map();
}

removeListener(event: keyof Events, listener: Events[typeof event]): void {
if (!this._hasEvent(event)) return
_addListener(
eventName: string | symbol,
listener: Function,
prepend: boolean
): this {
this.emit("newListener", eventName, listener);
if (this.events.has(eventName)) {
const listeners = this.events.get(eventName) as Function[];
if (prepend) {
listeners.unshift(listener);
} else {
listeners.push(listener);
}
} else {
this.events.set(eventName, [listener]);
}
const max = this.getMaxListeners();
if (max > 0 && this.listenerCount(eventName) > max) {
const warning = new Error(
`Possible EventEmitter memory leak detected.
${this.listenerCount(eventName)} ${eventName.toString()} listeners.
Use emitter.setMaxListeners() to increase limit`
);
warning.name = "MaxListenersExceededWarning";
console.warn(warning);
}

const index = this._events[event].indexOf(listener)
if (~index) this._events[event].splice(index, 1)
return this;
}

emit(event: keyof Events, ...args: Parameters<Events[typeof event]>): void {
if (!this._hasEvent(event)) return
addListener(eventName: string | symbol, listener: Function): this {
return this._addListener(eventName, listener, false);
}

emit(eventName: string | symbol, ...args: any[]): boolean {
if (this.events.has(eventName)) {
const listeners = (this.events.get(eventName) as Function[]).slice(); // We copy with slice() so array is not mutated during emit
for (const listener of listeners) {
try {
listener.apply(this, args);
} catch (err) {
this.emit("error", err);
}
}
return true;
} else if (eventName === "error") {
const errMsg = args.length > 0 ? args[0] : Error("Unhandled error.");
throw errMsg;
}
return false;
}

eventNames(): [string | symbol] {
return Array.from(this.events.keys()) as [string | symbol];
}

getMaxListeners(): number {
return this.maxListeners || EventEmitter.defaultMaxListeners;
}

listenerCount(eventName: string | symbol): number {
if (this.events.has(eventName)) {
return (this.events.get(eventName) as Function[]).length;
} else {
return 0;
}
}

_listeners(
target: EventEmitter,
eventName: string | symbol,
unwrap: boolean
): Function[] {
if (!target.events.has(eventName)) {
return [];
}

const eventListeners: ListenerFunction[] = target.events.get(
eventName
) as Function[];

return unwrap
? this.unwrapListeners(eventListeners)
: eventListeners.slice(0);
}

unwrapListeners(arr: ListenerFunction[]): Function[] {
let unwrappedListeners: Function[] = new Array(arr.length) as Function[];
for (let i = 0; i < arr.length; i++) {
unwrappedListeners[i] = arr[i]["listener"] || arr[i];
}
return unwrappedListeners;
}

listeners(eventName: string | symbol): Function[] {
return this._listeners(this, eventName, true);
}

rawListeners(eventName: string | symbol): Function[] {
return this._listeners(this, eventName, false);
}

off(eventName: string | symbol, listener: Function): this {
return this.removeListener(eventName, listener);
}

on(eventName: string | symbol, listener: Function): this {
return this.addListener(eventName, listener);
}

once(eventName: string | symbol, listener: Function): this {
const wrapped: Function = this.onceWrap(eventName, listener);
this.on(eventName, wrapped);
return this;
}

// Wrapped function that calls EventEmitter.removeListener(eventName, self) on execution.
onceWrap(eventName: string | symbol, listener: Function): Function {
const wrapper: ListenerFunction = function (
this: {
eventName: string | symbol;
listener: Function;
rawListener: Function;
context: EventEmitter;
},
...args: any[] // eslint-disable-line @typescript-eslint/no-explicit-any
): void {
this.context.removeListener(this.eventName, this.rawListener);
this.listener.apply(this.context, args);
};
const wrapperContext = {
eventName: eventName,
listener: listener,
rawListener: wrapper,
context: this
};
const wrapped = wrapper.bind(wrapperContext);
wrapperContext.rawListener = wrapped;
wrapped.listener = listener;
return wrapped;
}

prependListener(eventName: string | symbol, listener: Function): this {
return this._addListener(eventName, listener, true);
}

prependOnceListener(
eventName: string | symbol,
listener: Function
): this {
const wrapped: Function = this.onceWrap(eventName, listener);
this.prependListener(eventName, wrapped);
return this;
}

removeAllListeners(eventName?: string | symbol): this {
if (this.events === undefined) {
return this;
}

if (eventName && this.events.has(eventName)) {
const listeners = (this.events.get(eventName) as Function[]).slice(); // Create a copy; We use it AFTER it's deleted.
this.events.delete(eventName);
for (const listener of listeners) {
this.emit("removeListener", eventName, listener);
}
} else {
const eventList: [string | symbol] = this.eventNames();
eventList.map((value: string | symbol) => {
this.removeAllListeners(value);
});
}

return this;
}

this._events[event].map((listener) => listener.apply(this, args))
removeListener(eventName: string | symbol, listener: Function): this {
if (this.events.has(eventName)) {
const arr: Function[] = this.events.get(eventName) as Function[];
if (arr.indexOf(listener) !== -1) {
arr.splice(arr.indexOf(listener), 1);
this.emit("removeListener", eventName, listener);
if (arr.length === 0) {
this.events.delete(eventName);
}
}
}
return this;
}

private _hasEvent(event: keyof Events) {
return Array.isArray(this._events[event])
setMaxListeners(n: number): this {
this.maxListeners = n;
return this;
}
}
}
5 changes: 4 additions & 1 deletion packages/core/src/i18n.ts
Expand Up @@ -53,7 +53,7 @@ type Events = {
missing: (event: MissingMessageEvent) => void
}

export class I18n extends EventEmitter<Events> {
export class I18n extends EventEmitter {
_locale: Locale
_locales: Locales
_localeData: AllLocaleData
Expand Down Expand Up @@ -191,6 +191,9 @@ export class I18n extends EventEmitter<Events> {
: translation
}


// hack for parsing unicode values inside a string to get parsed in react native environments
if (isString(translation) && /\\u[a-fA-F0-9]{4}/g.test(translation)) return JSON.parse(`"${translation}"`)
if (isString(translation)) return translation

return interpolate(
Expand Down
5 changes: 1 addition & 4 deletions packages/macro/src/macroJs.ts
Expand Up @@ -8,7 +8,6 @@ import { COMMENT, ID, MESSAGE, EXTRACT_MARK } from "./constants"

const keepSpaceRe = /(?:\\(?:\r\n|\r|\n))+\s+/g
const keepNewLineRe = /(?:\r\n|\r|\n)+\s+/g
const removeExtraScapedLiterals = /(?:\\(.))/g

function normalizeWhitespace(text) {
return text.replace(keepSpaceRe, " ").replace(keepNewLineRe, "\n").trim()
Expand Down Expand Up @@ -360,10 +359,8 @@ export default class MacroJs {
* We clean '//\` ' to just '`'
*/
clearBackslashes(value: string) {
// it's an unicode char so we should keep them
if (value.includes('\\u')) return value.replace(removeExtraScapedLiterals, "\/u")
// if not we replace the extra scaped literals
return value.replace(removeExtraScapedLiterals, "`")
return value.replace(/\\`/g, "`")
}

/**
Expand Down
5 changes: 1 addition & 4 deletions packages/macro/src/macroJsx.ts
Expand Up @@ -7,7 +7,6 @@ import { zip, makeCounter } from "./utils"
import { ID, COMMENT, MESSAGE } from "./constants"

const pluralRuleRe = /(_[\d\w]+|zero|one|two|few|many|other)/
const removeExtraScapedLiterals = /(?:\\(.))/g
const jsx2icuExactChoice = (value) =>
value.replace(/_(\d+)/, "=$1").replace(/_(\w+)/, "$1")

Expand Down Expand Up @@ -364,10 +363,8 @@ export default class MacroJSX {
* We clean '//\` ' to just '`'
* */
clearBackslashes(value: string) {
// it's an unicode char so we should keep them
if (value.includes('\\u')) return value.replace(removeExtraScapedLiterals, "\/u")
// if not we replace the extra scaped literals
return value.replace(removeExtraScapedLiterals, "`")
return value.replace(/\\`/g, "`")
}

/**
Expand Down
28 changes: 14 additions & 14 deletions packages/react/src/I18nProvider.test.tsx
Expand Up @@ -46,20 +46,20 @@ describe("I18nProvider", () => {
expect(i18n.on).toBeCalledWith("change", expect.anything())
})

it("should unsubscribe for locale changes on unmount", () => {
const unsubscribe = jest.fn()
const i18n = setupI18n()
i18n.on = jest.fn(() => unsubscribe)

const { unmount } = render(
<I18nProvider i18n={i18n}>
<div />
</I18nProvider>
)
expect(unsubscribe).not.toBeCalled()
unmount()
expect(unsubscribe).toBeCalled()
})
// it("should unsubscribe for locale changes on unmount", () => {
// const unsubscribe = jest.fn()
// const i18n = setupI18n()
// i18n.on = jest.fn(() => unsubscribe)

// const { unmount } = render(
// <I18nProvider i18n={i18n}>
// <div />
// </I18nProvider>
// )
// expect(unsubscribe).not.toBeCalled()
// unmount()
// expect(unsubscribe).toBeCalled()
// })

it("should re-render on locale changes", async () => {
expect.assertions(3)
Expand Down
3 changes: 1 addition & 2 deletions packages/react/src/I18nProvider.tsx
Expand Up @@ -94,7 +94,7 @@ export const I18nProvider: FunctionComponent<I18nProviderProps> = ({
* async.
*/
React.useEffect(() => {
const unsubscribe = i18n.on("change", () => {
i18n.on("change", () => {
setContext(makeContext())
setRenderKey(getRenderKey())
})
Expand All @@ -104,7 +104,6 @@ export const I18nProvider: FunctionComponent<I18nProviderProps> = ({
if (forceRenderOnLocaleChange && renderKey === 'default') {
console.log("I18nProvider did not render. A call to i18n.activate still needs to happen or forceRenderOnLocaleChange must be set to false.")
}
return () => unsubscribe()
}, [])

if (forceRenderOnLocaleChange && renderKey === 'default') return null
Expand Down

1 comment on commit 39fa90d

@vercel
Copy link

@vercel vercel bot commented on 39fa90d Apr 9, 2021

Choose a reason for hiding this comment

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

Please sign in to comment.