2
0
mirror of https://github.com/iconify/iconify.git synced 2024-12-13 14:13:06 +00:00

chore(core): refactor loader to check icon names only in API calls

This commit is contained in:
Vjacheslav Trushkin 2024-11-03 09:35:48 +02:00
parent 7f27d10da6
commit 51c5b29fcd
5 changed files with 518 additions and 332 deletions

View File

@ -1,5 +1,9 @@
import type { IconifyIcon, IconifyJSON } from '@iconify/types'; import type { IconifyIcon, IconifyJSON } from '@iconify/types';
import { IconifyIconName, stringToIcon } from '@iconify/utils/lib/icon/name'; import {
IconifyIconName,
matchIconName,
stringToIcon,
} from '@iconify/utils/lib/icon/name';
import type { SortedIcons } from '../icon/sort'; import type { SortedIcons } from '../icon/sort';
import { sortIcons } from '../icon/sort'; import { sortIcons } from '../icon/sort';
import { storeCallback, updateCallbacks } from './callbacks'; import { storeCallback, updateCallbacks } from './callbacks';
@ -61,6 +65,71 @@ function loadedNewIcons(storage: IconStorageWithAPI): void {
} }
} }
interface CheckIconNames {
valid: string[];
invalid: string[];
}
/**
* Check icon names for API
*/
function checkIconNamesForAPI(icons: string[]): CheckIconNames {
const valid: string[] = [];
const invalid: string[] = [];
icons.forEach((name) => {
(name.match(matchIconName) ? valid : invalid).push(name);
});
return {
valid,
invalid,
};
}
/**
* Parse loader response
*/
function parseLoaderResponse(
storage: IconStorageWithAPI,
icons: string[],
data: unknown
) {
const fail = () => {
icons.forEach((name) => {
storage.missing.add(name);
});
};
// Check for error
if (typeof data !== 'object' || !data) {
fail();
} else {
// Add icons to storage
try {
const parsed = addIconSet(storage, data as IconifyJSON);
if (!parsed.length) {
fail();
return;
}
// Remove added icons from pending list
const pending = storage.pendingIcons;
if (pending) {
parsed.forEach((name) => {
pending.delete(name);
});
}
// Cache API response
storeInBrowserStorage(storage, data as IconifyJSON);
} catch (err) {
console.error(err);
}
}
// Trigger update on next tick
loadedNewIcons(storage);
}
/** /**
* Load icons * Load icons
*/ */
@ -80,54 +149,40 @@ function loadNewIcons(storage: IconStorageWithAPI, icons: string[]): void {
const { provider, prefix } = storage; const { provider, prefix } = storage;
// Get icons and delete queue // Get icons and delete queue
// Icons should not be undefined, but just in case assume it can be
const icons = storage.iconsToLoad; const icons = storage.iconsToLoad;
delete storage.iconsToLoad; delete storage.iconsToLoad;
// TODO: check for custom loader
// Using API loader
// Validate icon names for API
const { valid, invalid } = checkIconNamesForAPI(icons || []);
if (invalid.length) {
// Invalid icons
parseLoaderResponse(storage, invalid, null);
}
if (!valid.length) {
// No valid icons to load
return;
}
// Get API module // Get API module
let api: ReturnType<typeof getAPIModule>; const api = prefix.match(matchIconName)
if (!icons || !(api = getAPIModule(provider))) { ? getAPIModule(provider)
// No icons or no way to load icons! : null;
if (!api) {
// API module not found
parseLoaderResponse(storage, valid, null);
return; return;
} }
// Prepare parameters and run queries // Prepare parameters and run queries
const params = api.prepare(provider, prefix, icons); const params = api.prepare(provider, prefix, valid);
params.forEach((item) => { params.forEach((item) => {
sendAPIQuery(provider, item, (data) => { sendAPIQuery(provider, item, (data) => {
// Check for error parseLoaderResponse(storage, item.icons, data);
if (typeof data !== 'object') {
// Not found: mark as missing
item.icons.forEach((name) => {
storage.missing.add(name);
});
} else {
// Add icons to storage
try {
const parsed = addIconSet(
storage,
data as IconifyJSON
);
if (!parsed.length) {
return;
}
// Remove added icons from pending list
const pending = storage.pendingIcons;
if (pending) {
parsed.forEach((name) => {
pending.delete(name);
});
}
// Cache API response
storeInBrowserStorage(storage, data as IconifyJSON);
} catch (err) {
console.error(err);
}
}
// Trigger update on next tick
loadedNewIcons(storage);
}); });
}); });
}); });
@ -232,9 +287,9 @@ export const loadIcons: IconifyLoadIcons = (
// Load icons on next tick to make sure result is not returned before callback is stored and // Load icons on next tick to make sure result is not returned before callback is stored and
// to consolidate multiple synchronous loadIcons() calls into one asynchronous API call // to consolidate multiple synchronous loadIcons() calls into one asynchronous API call
sources.forEach((storage) => { sources.forEach((storage) => {
const { provider, prefix } = storage; const list = newIcons[storage.provider][storage.prefix];
if (newIcons[provider][prefix].length) { if (list.length) {
loadNewIcons(storage, newIcons[provider][prefix]); loadNewIcons(storage, list);
} }
}); });

View File

@ -21,6 +21,7 @@ const detectFetch = (): FetchType | undefined => {
if (typeof callback === 'function') { if (typeof callback === 'function') {
return callback; return callback;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) { } catch (err) {
// //
} }

View File

@ -311,9 +311,9 @@ describe('Testing API loadIcons', () => {
expect(loadedIcon).toBe(false); expect(loadedIcon).toBe(false);
// Test isPending // Test isPending
expect(isPending({ provider, prefix, name: 'BadIconName' })).toBe( // After change to naming convention, icon name is valid and should be pending
false // Filtering invalid names is done in loader, not in API module
); expect(isPending({ provider, prefix, name: 'BadIconName' })).toBe(true);
}); });
it('Loading one icon twice with Promise', () => { it('Loading one icon twice with Promise', () => {

View File

@ -29,102 +29,110 @@ describe('Testing mock API module', () => {
// Tests // Tests
it('404 response', () => { it('404 response', () => {
return new Promise((fulfill) => { return new Promise((resolve, reject) => {
const prefix = nextPrefix(); const prefix = nextPrefix();
mockAPIData({
type: 'icons',
provider,
prefix,
icons: ['test1', 'test2'],
response: 404,
});
let isSync = true; let isSync = true;
loadIcons( try {
[ mockAPIData({
{ type: 'icons',
provider, provider,
prefix, prefix,
name: 'test1', icons: ['test1', 'test2'],
}, response: 404,
], });
(loaded, missing, pending) => {
expect(isSync).toBe(false); loadIcons(
expect(loaded).toEqual([]); [
expect(pending).toEqual([]);
expect(missing).toEqual([
{ {
provider, provider,
prefix, prefix,
name: 'test1', name: 'test1',
}, },
]); ],
fulfill(true); (loaded, missing, pending) => {
} try {
); expect(isSync).toBe(false);
expect(loaded).toEqual([]);
expect(pending).toEqual([]);
expect(missing).toEqual([
{
provider,
prefix,
name: 'test1',
},
]);
} catch (error) {
reject(error);
return;
}
resolve(true);
}
);
} catch (error) {
reject(error);
}
isSync = false; isSync = false;
}); });
}); });
it('Load few icons', () => { it('Load few icons', () => {
return new Promise((fulfill) => { return new Promise((resolve, reject) => {
const prefix = nextPrefix(); const prefix = nextPrefix();
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
test10: {
body: '<g />',
},
test11: {
body: '<g />',
},
},
},
});
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
test20: {
body: '<g />',
},
test21: {
body: '<g />',
},
},
},
});
let isSync = true; let isSync = true;
loadIcons( try {
[ mockAPIData({
{ type: 'icons',
provider, provider,
prefix,
response: {
prefix, prefix,
name: 'test10', icons: {
test10: {
body: '<g />',
},
test11: {
body: '<g />',
},
},
}, },
{ });
provider, mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix, prefix,
name: 'test20', icons: {
test20: {
body: '<g />',
},
test21: {
body: '<g />',
},
},
}, },
], });
(loaded, missing, pending) => { // Data with invalid name: should not be requested from API because name is invalid
expect(isSync).toBe(false); // Split from main data because otherwise it would load when other icons are requested
// All icons should have been loaded because API waits one tick before sending response, during which both queries are processed mockAPIData({
expect(loaded).toEqual([ type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
BadName: {
body: '<g />',
},
},
},
});
loadIcons(
[
{ {
provider, provider,
prefix, prefix,
@ -135,283 +143,362 @@ describe('Testing mock API module', () => {
prefix, prefix,
name: 'test20', name: 'test20',
}, },
]); {
expect(pending).toEqual([]); provider,
expect(missing).toEqual([]); prefix,
fulfill(true); name: 'BadName',
} },
); ],
(loaded, missing, pending) => {
try {
if (pending.length) {
// Not ready to test yet
return;
}
expect(isSync).toBe(false);
// All icons should have been loaded because API waits one tick before sending response, during which both queries are processed
expect(loaded).toEqual([
{
provider,
prefix,
name: 'test10',
},
{
provider,
prefix,
name: 'test20',
},
]);
expect(pending).toEqual([]);
expect(missing).toEqual([
{
provider,
prefix,
name: 'BadName',
},
]);
} catch (error) {
reject(error);
return;
}
resolve(true);
}
);
} catch (error) {
reject(error);
}
isSync = false; isSync = false;
}); });
}); });
it('Load in batches and testing delay', () => { it('Load in batches and testing delay', () => {
return new Promise((fulfill, reject) => { return new Promise((resolve, reject) => {
const prefix = nextPrefix(); const prefix = nextPrefix();
let next: IconifyMockAPIDelayDoneCallback | undefined; let next: IconifyMockAPIDelayDoneCallback | undefined;
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
test10: {
body: '<g />',
},
test11: {
body: '<g />',
},
},
},
});
mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix,
icons: {
test20: {
body: '<g />',
},
test21: {
body: '<g />',
},
},
},
delay: (callback) => {
next = callback;
},
});
let callbackCounter = 0; let callbackCounter = 0;
loadIcons( try {
[ mockAPIData({
{ type: 'icons',
provider, provider,
prefix,
response: {
prefix, prefix,
name: 'test10', icons: {
test10: {
body: '<g />',
},
test11: {
body: '<g />',
},
},
}, },
{ });
provider, mockAPIData({
type: 'icons',
provider,
prefix,
response: {
prefix, prefix,
name: 'test20', icons: {
test20: {
body: '<g />',
},
test21: {
body: '<g />',
},
},
}, },
], delay: (callback) => {
(loaded, missing, pending) => { next = callback;
callbackCounter++; },
switch (callbackCounter) { });
case 1:
// First load: only 'test10'
expect(loaded).toEqual([
{
provider,
prefix,
name: 'test10',
},
]);
expect(pending).toEqual([
{
provider,
prefix,
name: 'test20',
},
]);
// Send second response loadIcons(
expect(typeof next).toBe('function'); [
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion {
next!(); provider,
break; prefix,
name: 'test10',
},
{
provider,
prefix,
name: 'test20',
},
],
(loaded, missing, pending) => {
callbackCounter++;
try {
switch (callbackCounter) {
case 1:
// First load: only 'test10'
expect(loaded).toEqual([
{
provider,
prefix,
name: 'test10',
},
]);
expect(pending).toEqual([
{
provider,
prefix,
name: 'test20',
},
]);
case 2: // Send second response
// All icons should have been loaded expect(typeof next).toBe('function');
expect(loaded).toEqual([ next!();
{ break;
provider,
prefix,
name: 'test10',
},
{
provider,
prefix,
name: 'test20',
},
]);
expect(missing).toEqual([]);
fulfill(true);
break;
default: case 2:
reject( // All icons should have been loaded
'Callback was called more times than expected' expect(loaded).toEqual([
); {
provider,
prefix,
name: 'test10',
},
{
provider,
prefix,
name: 'test20',
},
]);
expect(missing).toEqual([]);
resolve(true);
break;
default:
reject(
'Callback was called more times than expected'
);
}
} catch (error) {
reject(error);
}
} }
} );
); } catch (error) {
reject(error);
}
}); });
}); });
// This is useful for testing component where loadIcons() cannot be accessed // This is useful for testing component where loadIcons() cannot be accessed
it('Using timer in callback for second test', () => { it('Using timer in callback for second test', () => {
return new Promise((fulfill) => { return new Promise((resolve, reject) => {
const prefix = nextPrefix(); const prefix = nextPrefix();
const name = 'test1'; const name = 'test1';
// Mock data try {
mockAPIData({ // Mock data
type: 'icons', mockAPIData({
provider, type: 'icons',
prefix,
response: {
prefix,
icons: {
[name]: {
body: '<g />',
},
},
},
delay: (next) => {
// Icon should not be loaded yet
const storage = getStorage(provider, prefix);
expect(iconInStorage(storage, name)).toBe(false);
// Set data
next();
// Icon should be loaded now
expect(iconInStorage(storage, name)).toBe(true);
fulfill(true);
},
});
// Load icons
loadIcons([
{
provider, provider,
prefix, prefix,
name, response: {
}, prefix,
]); icons: {
[name]: {
body: '<g />',
},
},
},
delay: (next) => {
try {
// Icon should not be loaded yet
const storage = getStorage(provider, prefix);
expect(iconInStorage(storage, name)).toBe(false);
// Set data
next();
// Icon should be loaded now
expect(iconInStorage(storage, name)).toBe(true);
} catch (error) {
reject(error);
return;
}
resolve(true);
},
});
// Load icons
loadIcons([
{
provider,
prefix,
name,
},
]);
} catch (error) {
reject(error);
}
}); });
}); });
it('Custom query', () => { it('Custom query', () => {
return new Promise((fulfill) => { return new Promise((resolve, reject) => {
mockAPIData({
type: 'custom',
provider,
uri: '/test',
response: {
foo: true,
},
});
let isSync = true; let isSync = true;
sendAPIQuery( try {
provider, mockAPIData({
{
type: 'custom', type: 'custom',
provider, provider,
uri: '/test', uri: '/test',
}, response: {
(data, error) => {
expect(error).toBeUndefined();
expect(data).toEqual({
foo: true, foo: true,
}); },
expect(isSync).toBe(false); });
fulfill(true);
} sendAPIQuery(
); provider,
{
type: 'custom',
provider,
uri: '/test',
},
(data, error) => {
try {
expect(error).toBeUndefined();
expect(data).toEqual({
foo: true,
});
expect(isSync).toBe(false);
} catch (error) {
reject(error);
return;
}
resolve(true);
}
);
} catch (error) {
reject(error);
}
isSync = false; isSync = false;
}); });
}); });
it('Custom query with host', () => { it('Custom query with host', () => {
return new Promise((fulfill) => { return new Promise((resolve, reject) => {
const host = 'http://' + nextPrefix(); const host = 'http://' + nextPrefix();
setAPIModule(host, mockAPIModule);
mockAPIData({
type: 'host',
host,
uri: '/test',
response: {
foo: 2,
},
});
let isSync = true; let isSync = true;
sendAPIQuery( try {
{ setAPIModule(host, mockAPIModule);
resources: [host], mockAPIData({
}, type: 'host',
{ host,
type: 'custom',
uri: '/test', uri: '/test',
}, response: {
(data, error) => {
expect(error).toBeUndefined();
expect(data).toEqual({
foo: 2, foo: 2,
}); },
expect(isSync).toBe(false); });
fulfill(true);
} sendAPIQuery(
); {
resources: [host],
},
{
type: 'custom',
uri: '/test',
},
(data, error) => {
try {
expect(error).toBeUndefined();
expect(data).toEqual({
foo: 2,
});
expect(isSync).toBe(false);
} catch (error) {
reject(error);
return;
}
resolve(true);
}
);
} catch (error) {
reject(error);
}
isSync = false; isSync = false;
}); });
}); });
it('not_found response', () => { it('not_found response', () => {
return new Promise((fulfill) => { return new Promise((resolve, reject) => {
const prefix = nextPrefix(); const prefix = nextPrefix();
mockAPIData({
type: 'icons',
provider,
prefix,
icons: ['test1', 'test2'],
response: {
prefix,
icons: {},
not_found: ['test1', 'test2'],
},
});
let isSync = true; let isSync = true;
loadIcons( try {
[ mockAPIData({
{ type: 'icons',
provider, provider,
prefix,
icons: ['test1', 'test2'],
response: {
prefix, prefix,
name: 'test1', icons: {},
not_found: ['test1', 'test2'],
}, },
], });
(loaded, missing, pending) => {
expect(isSync).toBe(false); loadIcons(
expect(loaded).toEqual([]); [
expect(pending).toEqual([]);
expect(missing).toEqual([
{ {
provider, provider,
prefix, prefix,
name: 'test1', name: 'test1',
}, },
]); ],
fulfill(true); (loaded, missing, pending) => {
} try {
); expect(isSync).toBe(false);
expect(loaded).toEqual([]);
expect(pending).toEqual([]);
expect(missing).toEqual([
{
provider,
prefix,
name: 'test1',
},
]);
} catch (error) {
reject(error);
return;
}
resolve(true);
}
);
} catch (error) {
reject(error);
}
isSync = false; isSync = false;
}); });

View File

@ -319,4 +319,47 @@ describe('Testing parsing icon set', () => {
parsedNames.sort((a, b) => a.localeCompare(b)); parsedNames.sort((a, b) => a.localeCompare(b));
expect(parsedNames).toEqual(expectedNames); expect(parsedNames).toEqual(expectedNames);
}); });
test('Bug test 1', () => {
// Names list
const names: string[] = ['test20', 'test21', 'BadName'];
// Resolved data
const expected: Record<string, ExtendedIconifyIcon | null> = {
test20: {
body: '<g />',
},
test21: {
body: '<g />',
},
BadName: {
body: '<g />',
},
};
// Do stuff
expect(
parseIconSet(
{
prefix: 'api-mock-02',
icons: {
test20: { body: '<g />' },
test21: { body: '<g />' },
BadName: { body: '<g />' },
},
},
(name, data) => {
// Make sure name matches
expect(names.length).toBeGreaterThanOrEqual(1);
expect(name).toBe(names.shift());
// Check icon data
expect(data).toEqual(expected[name]);
}
)
).toEqual(['test20', 'test21', 'BadName']);
// All names should have been parsed
expect(names).toEqual([]);
});
}); });