204 lines
6.4 KiB
JavaScript
204 lines
6.4 KiB
JavaScript
import { useContext, useCallback, useMemo, useEffect, useRef, useState } from 'react';
|
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
import { useSyncExternalStore } from 'use-sync-external-store/shim';
|
|
import { getI18n, getDefaults, ReportNamespaces, I18nContext } from './context.js';
|
|
import {
|
|
warnOnce,
|
|
loadNamespaces,
|
|
loadLanguages,
|
|
hasLoadedNamespace,
|
|
isString,
|
|
isObject,
|
|
} from './utils.js';
|
|
|
|
const notReadyT = (k, optsOrDefaultValue) => {
|
|
if (isString(optsOrDefaultValue)) return optsOrDefaultValue;
|
|
if (isObject(optsOrDefaultValue) && isString(optsOrDefaultValue.defaultValue))
|
|
return optsOrDefaultValue.defaultValue;
|
|
return Array.isArray(k) ? k[k.length - 1] : k;
|
|
};
|
|
|
|
const notReadySnapshot = { t: notReadyT, ready: false };
|
|
const dummySubscribe = () => () => {};
|
|
|
|
export const useTranslation = (ns, props = {}) => {
|
|
const { i18n: i18nFromProps } = props;
|
|
const { i18n: i18nFromContext, defaultNS: defaultNSFromContext } = useContext(I18nContext) || {};
|
|
const i18n = i18nFromProps || i18nFromContext || getI18n();
|
|
|
|
if (i18n && !i18n.reportNamespaces) i18n.reportNamespaces = new ReportNamespaces();
|
|
|
|
if (!i18n) {
|
|
warnOnce(
|
|
i18n,
|
|
'NO_I18NEXT_INSTANCE',
|
|
'useTranslation: You will need to pass in an i18next instance by using initReactI18next',
|
|
);
|
|
}
|
|
|
|
const i18nOptions = useMemo(
|
|
() => ({ ...getDefaults(), ...i18n?.options?.react, ...props }),
|
|
[i18n, props],
|
|
);
|
|
|
|
const { useSuspense, keyPrefix } = i18nOptions;
|
|
|
|
const nsOrContext = ns || defaultNSFromContext || i18n?.options?.defaultNS;
|
|
const unstableNamespaces = isString(nsOrContext) ? [nsOrContext] : nsOrContext || ['translation'];
|
|
const namespaces = useMemo(() => unstableNamespaces, unstableNamespaces);
|
|
|
|
i18n?.reportNamespaces?.addUsedNamespaces?.(namespaces);
|
|
|
|
const revisionRef = useRef(0);
|
|
const subscribe = useCallback(
|
|
(callback) => {
|
|
if (!i18n) return dummySubscribe;
|
|
const { bindI18n, bindI18nStore } = i18nOptions;
|
|
|
|
const wrappedCallback = () => {
|
|
revisionRef.current += 1;
|
|
callback();
|
|
};
|
|
|
|
if (bindI18n) i18n.on(bindI18n, wrappedCallback);
|
|
if (bindI18nStore) i18n.store.on(bindI18nStore, wrappedCallback);
|
|
return () => {
|
|
if (bindI18n) bindI18n.split(' ').forEach((e) => i18n.off(e, wrappedCallback));
|
|
if (bindI18nStore)
|
|
bindI18nStore.split(' ').forEach((e) => i18n.store.off(e, wrappedCallback));
|
|
};
|
|
},
|
|
[i18n, i18nOptions],
|
|
);
|
|
|
|
const snapshotRef = useRef();
|
|
const getSnapshot = useCallback(() => {
|
|
if (!i18n) {
|
|
return notReadySnapshot;
|
|
}
|
|
const calculatedReady =
|
|
!!(i18n.isInitialized || i18n.initializedStoreOnce) &&
|
|
namespaces.every((n) => hasLoadedNamespace(n, i18n, i18nOptions));
|
|
const currentLng = props.lng || i18n.language;
|
|
const currentRevision = revisionRef.current;
|
|
|
|
const lastSnapshot = snapshotRef.current;
|
|
if (
|
|
lastSnapshot &&
|
|
lastSnapshot.ready === calculatedReady &&
|
|
lastSnapshot.lng === currentLng &&
|
|
lastSnapshot.keyPrefix === keyPrefix &&
|
|
lastSnapshot.revision === currentRevision // Check revision
|
|
) {
|
|
return lastSnapshot;
|
|
}
|
|
|
|
const calculatedT = i18n.getFixedT(
|
|
currentLng,
|
|
i18nOptions.nsMode === 'fallback' ? namespaces : namespaces[0],
|
|
keyPrefix,
|
|
);
|
|
|
|
const newSnapshot = {
|
|
t: calculatedT,
|
|
ready: calculatedReady,
|
|
lng: currentLng,
|
|
keyPrefix,
|
|
revision: currentRevision, // Store revision
|
|
};
|
|
snapshotRef.current = newSnapshot;
|
|
return newSnapshot;
|
|
}, [i18n, namespaces, keyPrefix, i18nOptions, props.lng]);
|
|
|
|
// We still need a state to manually trigger a re-render on load when the store doesn't emit an event.
|
|
const [loadCount, setLoadCount] = useState(0);
|
|
const { t, ready } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
|
|
useEffect(() => {
|
|
if (i18n && !ready && !useSuspense) {
|
|
const onLoaded = () => setLoadCount((c) => c + 1);
|
|
if (props.lng) {
|
|
loadLanguages(i18n, props.lng, namespaces, onLoaded);
|
|
} else {
|
|
loadNamespaces(i18n, namespaces, onLoaded);
|
|
}
|
|
}
|
|
}, [i18n, props.lng, namespaces, ready, useSuspense, loadCount]);
|
|
|
|
const finalI18n = i18n || {};
|
|
|
|
// cache one wrapper per hook caller and only recreate it when language changes
|
|
const wrapperRef = useRef(null);
|
|
const wrapperLangRef = useRef();
|
|
|
|
// helper to create a wrapper instance (avoid duplicating descriptor logic)
|
|
const createI18nWrapper = (original) => {
|
|
const descriptors = Object.getOwnPropertyDescriptors(original);
|
|
if (descriptors.__original) delete descriptors.__original;
|
|
const wrapper = Object.create(Object.getPrototypeOf(original), descriptors);
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(wrapper, '__original')) {
|
|
try {
|
|
Object.defineProperty(wrapper, '__original', {
|
|
value: original,
|
|
writable: false,
|
|
enumerable: false,
|
|
configurable: false,
|
|
});
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
return wrapper;
|
|
};
|
|
|
|
const ret = useMemo(() => {
|
|
const original = finalI18n;
|
|
const lang = original?.language;
|
|
|
|
let i18nWrapper = original;
|
|
|
|
if (original) {
|
|
// if we already created a wrapper for this original instance
|
|
if (wrapperRef.current && wrapperRef.current.__original === original) {
|
|
// language changed -> create fresh wrapper so identity changes
|
|
if (wrapperLangRef.current !== lang) {
|
|
i18nWrapper = createI18nWrapper(original);
|
|
|
|
wrapperRef.current = i18nWrapper;
|
|
wrapperLangRef.current = lang;
|
|
} else {
|
|
// reuse existing wrapper when language didn't change
|
|
i18nWrapper = wrapperRef.current;
|
|
}
|
|
} else {
|
|
// first time for this original instance -> create wrapper
|
|
i18nWrapper = createI18nWrapper(original);
|
|
|
|
wrapperRef.current = i18nWrapper;
|
|
wrapperLangRef.current = lang;
|
|
}
|
|
}
|
|
|
|
const arr = [t, i18nWrapper, ready];
|
|
arr.t = t;
|
|
arr.i18n = i18nWrapper;
|
|
arr.ready = ready;
|
|
return arr;
|
|
}, [t, finalI18n, ready, finalI18n.resolvedLanguage, finalI18n.language, finalI18n.languages]);
|
|
|
|
if (i18n && useSuspense && !ready) {
|
|
throw new Promise((resolve) => {
|
|
const onLoaded = () => resolve();
|
|
if (props.lng) {
|
|
loadLanguages(i18n, props.lng, namespaces, onLoaded);
|
|
} else {
|
|
loadNamespaces(i18n, namespaces, onLoaded);
|
|
}
|
|
});
|
|
}
|
|
|
|
return ret;
|
|
};
|