Emoji loading fixes (#37300)

This commit is contained in:
Echo 2025-12-18 17:58:44 +01:00 committed by GitHub
parent a8109e50fc
commit ba4710debe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 138 additions and 135 deletions

View File

@ -5,11 +5,7 @@ import { openDB } from 'idb';
import { EMOJI_DB_SHORTCODE_TEST } from './constants'; import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { import type { CustomEmojiData, UnicodeEmojiData, EtagTypes } from './types';
CustomEmojiData,
UnicodeEmojiData,
LocaleOrCustom,
} from './types';
import { emojiLogger } from './utils'; import { emojiLogger } from './utils';
interface EmojiDB extends LocaleTables, DBSchema { interface EmojiDB extends LocaleTables, DBSchema {
@ -32,7 +28,7 @@ interface EmojiDB extends LocaleTables, DBSchema {
}; };
}; };
etags: { etags: {
key: LocaleOrCustom; key: EtagTypes;
value: string; value: string;
}; };
} }
@ -197,10 +193,9 @@ export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
await trx.done; await trx.done;
} }
export async function putLatestEtag(etag: string, localeString: string) { export async function putLatestEtag(etag: string, name: EtagTypes) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB(); const db = await loadDB();
await db.put('etags', etag, locale); await db.put('etags', etag, name);
} }
export async function clearEtag(localeString: string) { export async function clearEtag(localeString: string) {

View File

@ -1,13 +1,9 @@
import type { Locale } from 'emojibase';
import { initialState } from '@/mastodon/initial_state'; import { initialState } from '@/mastodon/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants'; import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types'; import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils'; import { emojiLogger } from './utils';
// eslint-disable-next-line import/default -- Importing via worker loader.
import EmojiWorker from './worker?worker&inline';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en'); const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
@ -18,13 +14,14 @@ const log = emojiLogger('index');
// This is too short, but better to fallback quickly than wait. // This is too short, but better to fallback quickly than wait.
const WORKER_TIMEOUT = 1_000; const WORKER_TIMEOUT = 1_000;
export function initializeEmoji() { export async function initializeEmoji() {
log('initializing emojis'); log('initializing emojis');
// Create a temp worker, and assign it to the module-level worker once we know it's ready. // Create a temp worker, and assign it to the module-level worker once we know it's ready.
let tempWorker: Worker | null = null; let tempWorker: Worker | null = null;
if (!worker && 'Worker' in window) { if (!worker && 'Worker' in window) {
try { try {
const { default: EmojiWorker } = await import('./worker?worker&inline');
tempWorker = new EmojiWorker(); tempWorker = new EmojiWorker();
} catch (err) { } catch (err) {
console.warn('Error creating web worker:', err); console.warn('Error creating web worker:', err);
@ -64,7 +61,7 @@ async function fallbackLoad() {
await loadCustomEmoji(); await loadCustomEmoji();
const { importLegacyShortcodes } = await import('./loader'); const { importLegacyShortcodes } = await import('./loader');
const shortcodes = await importLegacyShortcodes(); const shortcodes = await importLegacyShortcodes();
if (shortcodes.length) { if (shortcodes?.length) {
log('loaded %d legacy shortcodes', shortcodes.length); log('loaded %d legacy shortcodes', shortcodes.length);
} }
await loadEmojiLocale(userLocale); await loadEmojiLocale(userLocale);
@ -72,14 +69,11 @@ async function fallbackLoad() {
async function loadEmojiLocale(localeString: string) { async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString); const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToEmojiPath, localeToShortcodesPath } = const { importEmojiData } = await import('./loader');
await import('./loader');
if (worker) { if (worker) {
const path = await localeToEmojiPath(locale); log('asking worker to load locale %s', locale);
const shortcodesPath = await localeToShortcodesPath(locale); messageWorker(locale);
log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path, shortcodesPath);
} else { } else {
const emojis = await importEmojiData(locale); const emojis = await importEmojiData(locale);
if (emojis) { if (emojis) {
@ -100,17 +94,11 @@ export async function loadCustomEmoji() {
} }
} }
function messageWorker(
locale: typeof EMOJI_TYPE_CUSTOM | typeof EMOJI_DB_NAME_SHORTCODES,
): void;
function messageWorker(locale: Locale, path: string, shortcodes?: string): void;
function messageWorker( function messageWorker(
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES, locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
path?: string,
shortcodes?: string,
) { ) {
if (!worker) { if (!worker) {
return; return;
} }
worker.postMessage({ locale, path, shortcodes }); worker.postMessage({ locale });
} }

View File

@ -13,46 +13,35 @@ import {
putLatestEtag, putLatestEtag,
putLegacyShortcodes, putLegacyShortcodes,
} from './database'; } from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toValidEtagName } from './locale';
import type { CustomEmojiData } from './types'; import type { CustomEmojiData } from './types';
import { emojiLogger } from './utils';
export async function importEmojiData( const log = emojiLogger('loader');
localeString: string,
path?: string, export async function importEmojiData(localeString: string, shortcodes = true) {
shortcodes: boolean | string = true,
) {
const locale = toSupportedLocale(localeString); const locale = toSupportedLocale(localeString);
// Validate the provided path. log(
if (path && !/^[/a-z]*\/packs\/assets\/compact-\w+\.json$/.test(path)) { 'importing emoji data for locale %s%s',
throw new Error('Invalid path for emoji data'); locale,
} else { shortcodes ? ' and shortcodes' : '',
// Otherwise get the path if not provided. );
path ??= await localeToEmojiPath(locale);
}
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path); const emojis = await fetchAndCheckEtag<CompactEmoji[]>({
etagString: locale,
path: localeToEmojiPath(locale),
});
if (!emojis) { if (!emojis) {
return; return;
} }
const shortcodesData: ShortcodesDataset[] = []; const shortcodesData: ShortcodesDataset[] = [];
if (shortcodes) { if (shortcodes) {
if ( const shortcodesResponse = await fetchAndCheckEtag<ShortcodesDataset>({
typeof shortcodes === 'string' && etagString: `${locale}-shortcodes`,
!/^[/a-z]*\/packs\/assets\/shortcodes\/cldr\.json$/.test(shortcodes) path: localeToShortcodesPath(locale),
) { });
throw new Error('Invalid path for shortcodes data');
}
const shortcodesPath =
typeof shortcodes === 'string'
? shortcodes
: await localeToShortcodesPath(locale);
const shortcodesResponse = await fetchAndCheckEtag<ShortcodesDataset>(
locale,
shortcodesPath,
false,
);
if (shortcodesResponse) { if (shortcodesResponse) {
shortcodesData.push(shortcodesResponse); shortcodesData.push(shortcodesResponse);
} else { } else {
@ -69,10 +58,10 @@ export async function importEmojiData(
} }
export async function importCustomEmojiData() { export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>( const emojis = await fetchAndCheckEtag<CustomEmojiData[]>({
'custom', etagString: 'custom',
'/api/v1/custom_emojis', path: '/api/v1/custom_emojis',
); });
if (!emojis) { if (!emojis) {
return; return;
} }
@ -81,76 +70,76 @@ export async function importCustomEmojiData() {
} }
export async function importLegacyShortcodes() { export async function importLegacyShortcodes() {
const { default: shortcodesPath } = const globPaths = import.meta.glob<string>(
await import('emojibase-data/en/shortcodes/iamcal.json?url'); // We use import.meta.glob to eagerly load the URL, as the regular import() doesn't work inside the Web Worker.
const response = await fetch(shortcodesPath); '../../../../../node_modules/emojibase-data/en/shortcodes/iamcal.json',
if (!response.ok) { { eager: true, import: 'default', query: '?url' },
throw new Error(
`Failed to fetch legacy shortcodes data: ${response.statusText}`,
); );
const path = Object.values(globPaths)[0];
if (!path) {
throw new Error('IAMCAL shortcodes path not found');
}
const shortcodesData = await fetchAndCheckEtag<ShortcodesDataset>({
checkEtag: true,
etagString: 'shortcodes',
path,
});
if (!shortcodesData) {
return;
} }
const shortcodesData = (await response.json()) as ShortcodesDataset;
await putLegacyShortcodes(shortcodesData); await putLegacyShortcodes(shortcodesData);
return Object.keys(shortcodesData); return Object.keys(shortcodesData);
} }
const emojiModules = new Map( function localeToEmojiPath(locale: Locale) {
Object.entries( const key = `../../../../../node_modules/emojibase-data/${locale}/compact.json`;
import.meta.glob<string>( const emojiModules = import.meta.glob<string>(
'../../../../../node_modules/emojibase-data/**/compact.json', '../../../../../node_modules/emojibase-data/**/compact.json',
{ {
query: '?url', query: '?url',
import: 'default', import: 'default',
eager: true,
}, },
),
).map(([key, loader]) => {
const match = /emojibase-data\/([^/]+)\/compact\.json$/.exec(key);
return [match?.at(1) ?? key, loader];
}),
); );
const path = emojiModules[key];
export function localeToEmojiPath(locale: Locale) {
const path = emojiModules.get(locale);
if (!path) { if (!path) {
throw new Error(`Unsupported locale: ${locale}`); throw new Error(`Unsupported locale: ${locale}`);
} }
return path(); return path;
} }
const shortcodesModules = new Map( function localeToShortcodesPath(locale: Locale) {
Object.entries( const key = `../../../../../node_modules/emojibase-data/${locale}/shortcodes/cldr.json`;
import.meta.glob<string>( const shortcodesModules = import.meta.glob<string>(
'../../../../../node_modules/emojibase-data/**/shortcodes/cldr.json', '../../../../../node_modules/emojibase-data/**/shortcodes/cldr.json',
{ {
query: '?url', query: '?url',
import: 'default', import: 'default',
eager: true,
}, },
),
).map(([key, loader]) => {
const match = /emojibase-data\/([^/]+)\/shortcodes\/cldr\.json$/.exec(key);
return [match?.at(1) ?? key, loader];
}),
); );
const path = shortcodesModules[key];
export function localeToShortcodesPath(locale: Locale) {
const path = shortcodesModules.get(locale);
if (!path) { if (!path) {
throw new Error(`Unsupported locale for shortcodes: ${locale}`); throw new Error(`Unsupported locale for shortcodes: ${locale}`);
} }
return path(); return path;
} }
export async function fetchAndCheckEtag<ResultType extends object[] | object>( async function fetchAndCheckEtag<ResultType extends object[] | object>({
localeString: string, etagString,
path: string, path,
checkEtag = true, checkEtag = false,
): Promise<ResultType | null> { }: {
const locale = toSupportedLocaleOrCustom(localeString); etagString: string;
path: string;
checkEtag?: boolean;
}): Promise<ResultType | null> {
const etagName = toValidEtagName(etagString);
// Use location.origin as this script may be loaded from a CDN domain. // Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin); const url = new URL(path, location.origin);
const oldEtag = checkEtag ? await loadLatestEtag(locale) : null; const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -163,7 +152,7 @@ export async function fetchAndCheckEtag<ResultType extends object[] | object>(
} }
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to fetch emoji data for ${locale}: ${response.statusText}`, `Failed to fetch emoji data for ${etagName}: ${response.statusText}`,
); );
} }
@ -172,7 +161,8 @@ export async function fetchAndCheckEtag<ResultType extends object[] | object>(
// Store the ETag for future requests // Store the ETag for future requests
const etag = response.headers.get('ETag'); const etag = response.headers.get('ETag');
if (etag && checkEtag) { if (etag && checkEtag) {
await putLatestEtag(etag, localeString); log(`storing new etag for ${etagName}: ${etag}`);
await putLatestEtag(etag, etagName);
} }
return data; return data;

View File

@ -1,7 +1,8 @@
import type { Locale } from 'emojibase'; import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase'; import { SUPPORTED_LOCALES } from 'emojibase';
import type { LocaleOrCustom } from './types'; import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import type { EtagTypes, LocaleOrCustom, LocaleWithShortcodes } from './types';
export function toSupportedLocale(localeBase: string): Locale { export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase(); const locale = localeBase.toLowerCase();
@ -12,12 +13,35 @@ export function toSupportedLocale(localeBase: string): Locale {
} }
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom { export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
if (locale.toLowerCase() === 'custom') { if (locale.toLowerCase() === EMOJI_TYPE_CUSTOM) {
return 'custom'; return EMOJI_TYPE_CUSTOM;
} }
return toSupportedLocale(locale); return toSupportedLocale(locale);
} }
function isSupportedLocale(locale: string): locale is Locale { export function toValidEtagName(input: string): EtagTypes {
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale); const lower = input.toLowerCase();
if (lower === EMOJI_TYPE_CUSTOM || lower === EMOJI_DB_NAME_SHORTCODES) {
return lower;
}
if (isLocaleWithShortcodes(lower)) {
return lower;
}
return toSupportedLocale(lower);
}
function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale);
}
function isLocaleWithShortcodes(input: string): input is LocaleWithShortcodes {
const [baseLocale, shortcodes] = input.split('-');
return (
!!baseLocale &&
!!shortcodes &&
isSupportedLocale(baseLocale) &&
shortcodes === EMOJI_DB_NAME_SHORTCODES
);
} }

View File

@ -4,12 +4,6 @@ import {
EMOJI_TYPE_UNICODE, EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM, EMOJI_TYPE_CUSTOM,
} from './constants'; } from './constants';
import {
loadEmojiByHexcode,
loadLegacyShortcodesByShortcode,
LocaleNotLoadedError,
} from './database';
import { importEmojiData } from './loader';
import { emojiToUnicodeHex } from './normalize'; import { emojiToUnicodeHex } from './normalize';
import type { import type {
EmojiLoadedState, EmojiLoadedState,
@ -121,6 +115,12 @@ export async function loadEmojiDataToState(
return null; return null;
} }
const {
loadLegacyShortcodesByShortcode,
loadEmojiByHexcode,
LocaleNotLoadedError,
} = await import('./database');
// First, try to load the data from IndexedDB. // First, try to load the data from IndexedDB.
try { try {
const legacyCode = await loadLegacyShortcodesByShortcode(state.code); const legacyCode = await loadLegacyShortcodesByShortcode(state.code);
@ -155,6 +155,7 @@ export async function loadEmojiDataToState(
state.code, state.code,
locale, locale,
); );
const { importEmojiData } = await import('./loader');
await importEmojiData(locale); // Use this from the loader file as it can be awaited. await importEmojiData(locale); // Use this from the loader file as it can be awaited.
return loadEmojiDataToState(state, locale, true); return loadEmojiDataToState(state, locale, true);
} }

View File

@ -7,6 +7,7 @@ import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import type { RequiredExcept } from '@/mastodon/utils/types'; import type { RequiredExcept } from '@/mastodon/utils/types';
import type { import type {
EMOJI_DB_NAME_SHORTCODES,
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI, EMOJI_MODE_TWEMOJI,
@ -20,6 +21,11 @@ export type EmojiMode =
| typeof EMOJI_MODE_TWEMOJI; | typeof EMOJI_MODE_TWEMOJI;
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM; export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
export type LocaleWithShortcodes = `${Locale}-shortcodes`;
export type EtagTypes =
| LocaleOrCustom
| typeof EMOJI_DB_NAME_SHORTCODES
| LocaleWithShortcodes;
export interface EmojiAppState { export interface EmojiAppState {
locales: Locale[]; locales: Locale[];

View File

@ -8,24 +8,23 @@ import {
addEventListener('message', handleMessage); addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread self.postMessage('ready'); // After the worker is ready, notify the main thread
function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) { function handleMessage(event: MessageEvent<{ locale: string }>) {
const { const {
data: { locale, path }, data: { locale },
} = event; } = event;
void loadData(locale, path); void loadData(locale);
} }
async function loadData(locale: string, path?: string) { async function loadData(locale: string) {
let importCount: number | undefined; let importCount: number | undefined;
if (locale === EMOJI_TYPE_CUSTOM) { if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length; importCount = (await importCustomEmojiData())?.length;
} else if (locale === EMOJI_DB_NAME_SHORTCODES) { } else if (locale === EMOJI_DB_NAME_SHORTCODES) {
importCount = (await importLegacyShortcodes()).length; importCount = (await importLegacyShortcodes())?.length;
} else if (path) {
importCount = (await importEmojiData(locale, path))?.length;
} else { } else {
throw new Error('Path is required for loading locale emoji data'); importCount = (await importEmojiData(locale))?.length;
} }
if (importCount) { if (importCount) {
self.postMessage(`loaded ${importCount} emojis into ${locale}`); self.postMessage(`loaded ${importCount} emojis into ${locale}`);
} }

View File

@ -30,7 +30,7 @@ function main() {
} }
const { initializeEmoji } = await import('./features/emoji/index'); const { initializeEmoji } = await import('./features/emoji/index');
initializeEmoji(); await initializeEmoji();
const root = createRoot(mountNode); const root = createRoot(mountNode);
root.render(<Mastodon {...props} />); root.render(<Mastodon {...props} />);