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:
parent
bc6b8665fa
commit
e7c0f3f292
1
crates/op-editor-ui/assets/iconify-catalog-brands.json
Normal file
1
crates/op-editor-ui/assets/iconify-catalog-brands.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/op-editor-ui/assets/iconify-catalog-core.json
Normal file
1
crates/op-editor-ui/assets/iconify-catalog-core.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue