Skip to content

Add lightweight batched glyph previews at a design location #150

Description

@kostyafarber

Problem

The glyph grid currently uses the editable-glyph loading path for every visible and overscanned glyph. Each snapshot can carry every authored source layer, interpolation state, component dependencies, editable identities, anchors, and transforms. With Fraunces (697 glyphs and 8 masters), the directory appears before grid outlines finish hydrating, and entering a glyph competes with background preview work.

The grid needs one lightweight derived outline per glyph at its current internal design location. It must not load or retain editable Glyph models for grid cells.

Follow-up to #149.

Delivery order

TanStack Query infrastructure should land independently:

  1. Open a small PR from main that adds @tanstack/react-query and installs one renderer-level QueryClientProvider.
  2. Keep preview-specific query keys, chunking, and defaults out of that foundation PR.
  3. Merge the Query foundation PR.
  4. Rebase the glyph-preview/grid PR onto the updated main.
  5. Implement the Rust projection, transport, and grid adoption together in the rebased PR.

This keeps dependency/provider review separate from the font-domain and grid-performance change.

Design principles

  • This is a read-only projection, not another glyph-loading API.
  • One request contains many glyph IDs at one internal design location.
  • Rust resolves interpolation and components and returns only the final preview path and advance.
  • The operation does not populate FontStore or create live Glyph models.
  • loadGlyph() remains the operation used when entering the editor.
  • Source metrics remain separate: they vary by location but are shared by every glyph in a batch and already resolve synchronously from the Rust-built source-metrics interpolation model.
  • External coordinates must be mapped before this operation. Preview resolution must not apply mappings a second time.
  • TanStack Query is a renderer concern. Query concepts must not appear in Rust, bridge DTOs, workspace transport, or Font's method signature.

Proposed API surface

Canonical Rust/wire result

#[serde(rename_all = "camelCase")]
pub struct GlyphPreview {
    pub glyph_id: GlyphId,
    /// Flattened root and component contours at the resolved location.
    pub svg_path: String,
    pub x_advance: f64,
}

Native bridge

#[napi]
pub fn get_glyph_previews(
    &self,
    glyph_ids: Vec<NapiGlyphId>,
    location: NapiLocation,
) -> Result<Vec<NapiGlyphPreview>>

Generated TypeScript:

export interface GlyphPreview {
  glyphId: GlyphId;
  svgPath: string;
  xAdvance: number;
}

export interface BridgeApi {
  getGlyphPreviews(
    glyphIds: GlyphId[],
    location: Location,
  ): GlyphPreview[];
}

No request or batch DTO is required. The location belongs to the call and later to the renderer query key.

Utility-process sync lane

type SyncCallMap = {
  // ...
  "workspace.glyphPreviews": {
    request: {
      glyphIds: GlyphId[];
      location: Location;
    };
    response: GlyphPreview[];
  };
};

WorkspaceHost forwards the request through the existing serialized workspace lane.

Renderer

class WorkspaceClient {
  glyphPreviews(
    glyphIds: readonly GlyphId[],
    location: Location,
  ): Promise<GlyphPreview[]>;
}

class WorkspaceEditCoordinator {
  readGlyphPreviews(
    glyphIds: readonly GlyphId[],
    location: Location,
  ): Promise<GlyphPreview[]>;
}

class Font {
  glyphPreviews(
    glyphIds: readonly GlyphId[],
    location: AxisLocation,
  ): Promise<readonly GlyphPreview[]>;
}

Use glyphPreviews, not loadGlyphPreviews: the operation returns derived values and must not mutate the live model.

Glyph resolution semantics

For every requested glyph:

  1. Use the exact authored layer when one exists at the requested internal design location.
  2. Otherwise evaluate the Rust-built glyph interpolation model at that location.
  3. If interpolation is unavailable, use the same default/primary-layer fallback as the editor.
  4. Resolve components recursively at the same location, including interpolated component transforms and anchor attachment.
  5. Skip cyclic component branches without dropping non-cyclic contours.
  6. Return the root glyph's resolved xAdvance.

The resolver should be a pure Rust projection built on existing interpolation and composite machinery, not a separate bridge-only interpolation implementation.

An existing blank glyph returns an empty svgPath with its resolved xAdvance. Missing glyph IDs are omitted, matching getGlyphSnapshots; existing results preserve request order.

Why SVG path data

The consumer is an SVG grid preview. Returning svgPath avoids nested point and contour objects crossing NAPI and Electron IPC, avoids shipping source layers and interpolation models, avoids creating renderer GlyphGeometry and GlyphOutline models, and lets the browser consume the result directly.

This is a purpose-built preview projection, not a general vector geometry API. Do not add bounds or packed commands without another concrete consumer.

Grid query design

The grid already uses TanStack Virtual. Divide the catalog into stable preview chunks (initially 32 or 64 glyphs) and request only chunks intersecting the virtualized range.

Use TanStack Query's useQueries with a stable key for each document, design location, and chunk:

useQueries({
  queries: visibleChunks.map((chunk) => ({
    queryKey: [
      "glyph-previews",
      documentId,
      locationKey(location),
      chunk.id,
    ],
    queryFn: () => font.glyphPreviews(chunk.glyphIds, location),
    staleTime: Infinity,
    gcTime: 30_000,
    retry: false,
  })),
});

Consequences:

  • a late scrub response remains attached to its old location key;
  • a scrolled-away response remains attached to its chunk key;
  • identical in-flight requests are deduplicated;
  • back-scrolling can reuse a recently resolved chunk;
  • the component needs no generation ref, pending ref, or manual stale-result comparison.

Do not create a preview cache class or wrapper around Query.

TanStack Query does not prevent distinct scrub locations from starting distinct native calls. Start without another scheduling library. If lightweight bounded batches still enqueue too much work, address location throttling or latest-pending native scheduling as measured follow-up work.

Source metrics while scrubbing

Source metrics are evaluated once for the current design location, not repeated on every GlyphPreview. They remain synchronous and local.

The UI must not combine an old-location outline with new-location metrics. During a transition, either withhold unresolved previews or retain the previous coherent grid frame and resolve its metrics from that frame's location. Choose the visual transition during grid integration.

Preview PR scope after the Query foundation lands

  • Add GlyphPreview to shift-wire.
  • Add a pure glyph-at-location preview resolver using existing interpolation semantics.
  • Resolve variable component glyphs at the parent location.
  • Add NAPI adapters and Bridge.getGlyphPreviews.
  • Regenerate the @shift/types bridge facade.
  • Add workspace.glyphPreviews to the utility sync lane.
  • Add WorkspaceClient, WorkspaceEditCoordinator, and Font query methods without populating FontStore.
  • Divide the catalog into stable preview chunks.
  • Replace grid loadGlyphs calls with TanStack useQueries preview batches.
  • Keep loadGlyph on cell activation before navigation to the editor.
  • Remove renderer preview bookkeeping made obsolete by the query path.
  • Document the read-only preview projection in bridge/workspace docs.
  • Check the relevant ROADMAP.md item if completed.

Verification

  • Static glyph returns its path and advance.
  • Exact variable source returns the authored result.
  • Intermediate locations interpolate outline coordinates and advance.
  • A variable component and its transform resolve at the parent location.
  • Nested components flatten correctly.
  • Component cycles skip only the cyclic branch.
  • Sparse or incompatible masters follow editor fallback semantics.
  • Blank glyphs remain distinguishable from missing IDs.
  • Batch output preserves request order for existing glyphs.
  • A parity fixture compares preview geometry and advance with the editor model at exact and interpolated locations.
  • Grid cells never load editable glyph snapshots for preview rendering.
  • Late results from previous design locations do not replace current-location previews.
  • Scrolling away and back can reuse a recent stable chunk.
  • Clicking a cell still loads the editable glyph and opens the editor.

Non-goals

  • Preview cache or manager classes.
  • Revision-stamped responses, diagnostics, or invalidation payloads.
  • General-purpose contour or point identities.
  • Server-side cancellation or prioritization in the first implementation.
  • Optimizing initial Designspace/UFO import.
  • Handling concurrent editor mutations while the grid is visible.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions