perf: lazy-load the iconify brand-logo catalog off the web first-load

The Iconify catalog was 41% of the web wasm; ~91% of its bytes are 3700
simple-icons brand logos vs only 0.46 MB for lucide+feather. Split it: embed the
core UI sets, load brands at runtime — desktop embeds + serves them
(/assets/iconify-catalog-brands.json), web fetches that route at mount and
registers via set_brand_catalog. Web wasm 13.7 -> 8.2 MB raw, 4.17 -> 2.18 MB
gzip (-48% over the wire); desktop keeps all icons. Brands registered in main()
before every native render path (GUI / --render-shots / MCP).
This commit is contained in:
Kayshen-X 2026-06-19 14:32:47 +08:00
parent bc6b8665fa
commit e7c0f3f292
11 changed files with 191 additions and 45891 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -46,6 +46,10 @@ pub use op_editor_core::render_backend::{
Color, ImageAdjustments, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout,
};
pub use theme::Theme;
// Brand-logo catalog registration, surfaced at the crate root so non-widget
// host code (the web brand-catalog fetcher) can install the runtime-loaded
// catalog without importing the boundary-restricted `widgets` facade.
pub use widgets::icon_catalog::{brand_catalog_loaded, set_brand_catalog};
/// Re-exports of Jian gesture / event types so widget code can use the
/// canonical Jian types directly via the short `crate::` form.

View file

@ -35,22 +35,70 @@ pub struct ParsedIconBody {
pub d: String,
}
const CATALOG_JSON: &str = include_str!("../../assets/iconify-catalog.json");
// Only the general-purpose UI sets (lucide + feather, ~0.46 MB) are embedded.
// The ~3700 simple-icons brand logos (~4.83 MB) are loaded at runtime via
// `set_brand_catalog` — embedded at startup on desktop, fetched from the daemon
// on web — so they never bloat the wasm first-load. Both stores yield `'static`
// references (each backed by a `OnceLock`).
const CORE_CATALOG_JSON: &str = include_str!("../../assets/iconify-catalog-core.json");
/// Brand-logo catalog (simple-icons): set once at runtime. `None` until loaded;
/// lookups / searches simply skip it while it is absent.
static BRAND_CATALOG: OnceLock<BrandCatalog> = OnceLock::new();
struct BrandCatalog {
icons: Vec<IconCatalogEntry>,
index: HashMap<String, usize>,
}
/// Install the brand-logo catalog from JSON (same shape as the core asset).
/// Returns `true` when newly installed, `false` if the JSON is invalid or the
/// catalog was already set (set-once; later calls are ignored).
pub fn set_brand_catalog(json: &str) -> bool {
if BRAND_CATALOG.get().is_some() {
return false;
}
let Ok(catalog) = serde_json::from_str::<Catalog>(json) else {
return false;
};
let index = catalog
.icons
.iter()
.enumerate()
.map(|(idx, icon)| (format!("{}:{}", icon.collection, icon.name), idx))
.collect();
BRAND_CATALOG
.set(BrandCatalog {
icons: catalog.icons,
index,
})
.is_ok()
}
/// Whether the brand-logo catalog has been loaded yet.
pub fn brand_catalog_loaded() -> bool {
BRAND_CATALOG.get().is_some()
}
pub fn lookup_icon(collection: &str, name: &str) -> Option<&'static IconCatalogEntry> {
let key = format!("{}:{}", collection.trim(), name.trim());
catalog_index()
if let Some(idx) = core_index().get(&key) {
return core_catalog().get(*idx);
}
let brands = BRAND_CATALOG.get()?;
brands
.index
.get(&key)
.and_then(|idx| catalog().get(*idx))
.and_then(|idx| brands.icons.get(*idx))
}
pub fn search_icons(query: &str, limit: usize) -> Vec<&'static IconCatalogEntry> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return catalog().iter().take(limit).collect();
return all_icons().take(limit).collect();
}
let mut out = Vec::new();
for icon in catalog() {
for icon in all_icons() {
if icon.name == query || format!("{}:{}", icon.collection, icon.name) == query {
out.push(icon);
if out.len() >= limit {
@ -58,7 +106,7 @@ pub fn search_icons(query: &str, limit: usize) -> Vec<&'static IconCatalogEntry>
}
}
}
for icon in catalog() {
for icon in all_icons() {
if (icon.name != query && matches_query(icon, &query))
|| format!("{}:{}", icon.collection, icon.name) == query
{
@ -71,6 +119,17 @@ pub fn search_icons(query: &str, limit: usize) -> Vec<&'static IconCatalogEntry>
out
}
/// Core (embedded) icons first, then brand logos if loaded. Both halves are
/// `'static`, so the chained iterator yields `&'static IconCatalogEntry`.
fn all_icons() -> impl Iterator<Item = &'static IconCatalogEntry> {
core_catalog().iter().chain(
BRAND_CATALOG
.get()
.into_iter()
.flat_map(|brands| brands.icons.iter()),
)
}
pub fn parse_iconify_body(body: &str, width: f32, height: f32) -> Option<ParsedIconBody> {
let mut paths = paths_from_body(body);
if paths.is_empty() {
@ -89,17 +148,19 @@ pub fn parse_iconify_body(body: &str, width: f32, height: f32) -> Option<ParsedI
})
}
fn catalog() -> &'static [IconCatalogEntry] {
fn core_catalog() -> &'static [IconCatalogEntry] {
static CATALOG: OnceLock<Catalog> = OnceLock::new();
&CATALOG
.get_or_init(|| serde_json::from_str(CATALOG_JSON).expect("bundled icon catalog is valid"))
.get_or_init(|| {
serde_json::from_str(CORE_CATALOG_JSON).expect("bundled core icon catalog is valid")
})
.icons
}
fn catalog_index() -> &'static HashMap<String, usize> {
fn core_index() -> &'static HashMap<String, usize> {
static INDEX: OnceLock<HashMap<String, usize>> = OnceLock::new();
INDEX.get_or_init(|| {
catalog()
core_catalog()
.iter()
.enumerate()
.map(|(idx, icon)| (format!("{}:{}", icon.collection, icon.name), idx))

View file

@ -166,8 +166,9 @@ fn first_party_icon_font_names_all_resolve() {
}
#[test]
fn bundled_iconify_catalog_contains_requested_collections() {
fn bundled_iconify_catalog_contains_core_collections() {
use crate::widgets::icon_catalog::{lookup_icon, IconRenderStyle};
// lucide + feather are embedded in the wasm/binary (the core set).
assert_eq!(
lookup_icon("lucide", "airplay").map(|i| i.style),
Some(IconRenderStyle::Stroke)
@ -176,6 +177,16 @@ fn bundled_iconify_catalog_contains_requested_collections() {
lookup_icon("feather", "airplay").map(|i| i.style),
Some(IconRenderStyle::Stroke)
);
}
#[test]
fn brand_logos_resolve_after_runtime_registration() {
use crate::widgets::icon_catalog::{lookup_icon, set_brand_catalog, IconRenderStyle};
// simple-icons brand logos are NOT embedded — they load at runtime
// (desktop: include_str at startup; web: fetched from the daemon). This test
// owns registering the real brands asset; the catalog is a set-once global,
// so other tests observe the same brand data regardless of ordering.
set_brand_catalog(include_str!("../../assets/iconify-catalog-brands.json"));
assert_eq!(
lookup_icon("simple-icons", "github").map(|i| i.style),
Some(IconRenderStyle::Fill)
@ -184,6 +195,11 @@ fn bundled_iconify_catalog_contains_requested_collections() {
#[test]
fn icon_font_node_paints_simple_icon_as_fill_path() {
// simple-icons are not embedded; register the brands catalog first
// (idempotent set-once — independent of test ordering).
crate::widgets::icon_catalog::set_brand_catalog(include_str!(
"../../assets/iconify-catalog-brands.json"
));
let mut b = CountingBackend::default();
paint_icon_font_node(
&mut b,

View file

@ -280,6 +280,8 @@ struct DesktopApp {
impl DesktopApp {
fn new(initial_file: Option<PathBuf>) -> Self {
// (The brand-logo catalog is registered once in `main` before any render
// path — GUI / `--render-shots` / MCP — so it is already loaded here.)
let mut host = WidgetHostNative::new();
let fit_blank_frame = initial_file.is_none();
// Best-effort prefs restore onto the host's `EditorState`.
@ -942,6 +944,12 @@ fn prompt_update_available(locale: op_editor_core::Locale, version: &str) {
}
fn main() {
// Register the brand-logo catalog (omitted from the wasm bundle, embedded in
// this binary) BEFORE any path that can render natively — the GUI app, the
// headless `--render-shots` rasterizer below, MCP — so they resolve
// simple-icons instead of the unknown-glyph fallback dot. Set-once /
// idempotent.
op_editor_ui::set_brand_catalog(web_static::ICONIFY_BRANDS_JSON);
// `--mcp` / `--mcp-http` swap the GUI for an MCP server mode;
// when one of those ran, exit instead of opening a window.
if mcp_serve::run_cli_if_requested() {

View file

@ -30,6 +30,16 @@ const INDEX_HTML: &str = include_str!("web_static/index.html");
/// 404 help page served when the wasm bundle cannot be found.
const MISSING_BUNDLE_HTML: &str = include_str!("web_static/missing_bundle.html");
/// The simple-icons brand-logo catalog (~4.8 MB). Embedded here so it can be
/// both (a) registered with the shared icon catalog at native GUI startup and
/// (b) served to the web client, which deliberately omits it from the wasm
/// bundle to keep the first-load small (see `op_editor_ui::widgets::icon_catalog`).
pub(crate) const ICONIFY_BRANDS_JSON: &str =
include_str!("../../op-editor-ui/assets/iconify-catalog-brands.json");
/// Route the web client fetches to load the brand-logo catalog at runtime.
pub(crate) const ICONIFY_BRANDS_PATH: &str = "/assets/iconify-catalog-brands.json";
/// The wasm-bindgen JS entry the host page imports; its presence marks a
/// directory as a usable bundle.
const BUNDLE_ENTRY_JS: &str = "op_host_web.js";
@ -219,6 +229,16 @@ pub(crate) fn handle_static_request(path: &str, bundle_dir: Option<&Path>) -> Op
Err(_) => not_found_reply(),
});
}
// Brand-logo catalog. The web bundle omits the ~4.8 MB simple-icons set to
// keep the wasm first-load small; the client fetches it from here once and
// registers it via `icon_catalog::set_brand_catalog`.
if path == ICONIFY_BRANDS_PATH {
return Some(StaticReply {
status: "200 OK",
content_type: "application/json",
body: ICONIFY_BRANDS_JSON.as_bytes().to_vec(),
});
}
None
}
@ -376,6 +396,19 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn iconify_brand_catalog_route_serves_embedded_json() {
let reply = handle_static_request(ICONIFY_BRANDS_PATH, None).expect("brands route");
assert_eq!(reply.status, "200 OK");
assert_eq!(reply.content_type, "application/json");
let body: serde_json::Value = serde_json::from_slice(&reply.body).expect("brands json");
let icons = body["icons"].as_array().expect("icons array");
assert!(icons
.iter()
.any(|icon| { icon["collection"] == "simple-icons" && icon["name"] == "github" }));
}
#[test]
fn index_serves_embedded_host_page_when_bundle_present() {
let dir = stub_bundle("index");

View file

@ -838,6 +838,9 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> {
// Populate the chat model picker from the daemon's `/api/ai/models`
// catalog (best-effort; async, repaints when the response lands).
crate::web_chat::fetch_models(&inner);
// Pull the brand-logo catalog (omitted from the wasm bundle) from the daemon
// in the background so the icon picker / figma can resolve simple-icons.
crate::iconify_web::fetch_brand_catalog(&inner);
// Bidirectional live-canvas sync with the daemon (pull on version bump,
// push local edits + selection) — same loops the skia mount wires.
crate::live_sync_glue::start(&inner);

View file

@ -33,6 +33,43 @@ const ICONIFY_API: &str = "https://api.iconify.design";
/// Same budget as the desktop worker's reqwest client.
const FETCH_TIMEOUT_MS: u32 = 15_000;
/// Daemon route serving the brand-logo catalog (simple-icons). MUST match
/// `op-host-desktop`'s `web_static::ICONIFY_BRANDS_PATH` — the daemon embeds the
/// asset the wasm bundle omits, like `/api/ai/models` for model discovery.
const BRAND_CATALOG_PATH: &str = "/assets/iconify-catalog-brands.json";
/// Fetch the brand-logo catalog from the daemon once at mount and register it
/// with the shared icon catalog. The wasm bundle omits these ~3700 simple-icons
/// (~4.8 MB) to keep the first-load small; this pulls them in the background so
/// the icon picker and figma icon substitution can resolve brand logos shortly
/// after load. Best-effort: a missing daemon / failed fetch just leaves brand
/// logos unavailable (lookups fall back to the unknown-glyph dot).
pub(crate) fn fetch_brand_catalog<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>) {
// Crate-root re-exports (not the `widgets` facade) so this stays within the
// op-host-web widget-boundary rule (`tools/check-widget-boundary.sh` F4).
if op_editor_ui::brand_catalog_loaded() {
return;
}
let base = crate::daemon_base::daemon_base();
let url = format!("{base}{BRAND_CATALOG_PATH}");
let inner_cb = inner.clone();
fetch_text(
&url,
Box::new(move |result| {
let Ok(body) = result else {
return;
};
if op_editor_ui::set_brand_catalog(&body) {
// Repaint so an open icon picker / brand iconFont nodes pick up
// the now-available logos.
if let Ok(mut b) = inner_cb.try_borrow_mut() {
let _ = b.repaint();
}
}
}),
);
}
/// One in-flight result page being assembled across the search +
/// per-collection fetches.
struct PendingPage {

View file

@ -5,7 +5,14 @@ import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const SETS = ['lucide', 'feather', 'simple-icons'];
const OUT = path.resolve('crates/op-editor-ui/assets/iconify-catalog.json');
// The catalog is split: the general-purpose UI sets (lucide + feather) are
// embedded in the wasm/binary; the ~3700 simple-icons brand logos are loaded at
// runtime (desktop: include_str at startup; web: fetched from the daemon) so
// they never bloat the wasm first-load.
const CORE_SETS = new Set(['lucide', 'feather']);
const OUT_DIR = path.resolve('crates/op-editor-ui/assets');
const OUT_CORE = path.join(OUT_DIR, 'iconify-catalog-core.json');
const OUT_BRANDS = path.join(OUT_DIR, 'iconify-catalog-brands.json');
function attr(tag, name) {
const match = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
@ -147,6 +154,11 @@ icons.sort((a, b) => {
return setDelta || a.name.localeCompare(b.name);
});
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, `${JSON.stringify({ icons })}\n`);
console.log(`wrote ${icons.length} icons to ${OUT}`);
const coreIcons = icons.filter((icon) => CORE_SETS.has(icon.collection));
const brandIcons = icons.filter((icon) => !CORE_SETS.has(icon.collection));
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT_CORE, JSON.stringify({ icons: coreIcons }));
fs.writeFileSync(OUT_BRANDS, JSON.stringify({ icons: brandIcons }));
console.log(`wrote ${coreIcons.length} core icons to ${OUT_CORE}`);
console.log(`wrote ${brandIcons.length} brand icons to ${OUT_BRANDS}`);