From 4c8064d4d3d1ffe51a10b1c7eee79a49b2fc468f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 7 Jul 2026 01:59:48 -0700 Subject: [PATCH] fix(cli): figma component import survives unrenderable nodes (#2022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing against a real community file (Ratings) found a nested instance node figma refuses to render as svg — which aborted the entire component import. The rasterize loop now retries the node as png, and only if both formats fail warns and skips THAT node (placeholder keeps its data-figma-rasterize marker, no src) instead of failing the import. On the file that surfaced this, the png retry recovers the node — 31/31 placeholders get assets. Co-authored-by: Claude Fable 5 --- .../cli/src/commands/figma/component.test.ts | 61 ++++++++++++++++++- packages/cli/src/commands/figma/component.ts | 46 +++++++++++--- packages/cli/src/telemetry/events.ts | 4 ++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts index a5d81b3ed..cc47031ec 100644 --- a/packages/cli/src/commands/figma/component.test.ts +++ b/packages/cli/src/commands/figma/component.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runComponentImport } from "./component.js"; -import { appendBinding, type FigmaClient } from "@hyperframes/core/figma"; +import { FigmaClientError, appendBinding, type FigmaClient } from "@hyperframes/core/figma"; let dir = ""; beforeEach(() => { @@ -94,4 +94,63 @@ describe("runComponentImport", () => { expect(item.files.some((f) => f.type === "hyperframes:snippet")).toBe(true); expect(out.name).toBe("hero-card"); }); + + it("skips nodes figma refuses to render instead of aborting the import", async () => { + const failing: FigmaClient = { + ...client(), + renderNode: () => + Promise.reject( + new FigmaClientError("RENDER_FAILED", "figma could not render node 1:3 as svg"), + ), + }; + const out = await runComponentImport("FILE:1-1", { + projectDir: dir, + client: failing, + download: () => Promise.resolve(SVG), + }); + const html = readFileSync(join(dir, out.htmlPath), "utf8"); + expect(html).toContain("data-figma-rasterize="); + expect(html).not.toContain("src="); + const registry = JSON.parse( + readFileSync(join(dir, "compositions", "components", out.name, "registry-item.json"), "utf8"), + ); + expect(registry.files).toHaveLength(1); + }); + + it("recovers a node via png when svg render fails", async () => { + let calls = 0; + const svgFailsPngWorks: FigmaClient = { + ...client(), + renderNode: (_ref, opts) => { + calls++; + if (opts.format === "svg") + return Promise.reject(new FigmaClientError("RENDER_FAILED", "no svg for you")); + return Promise.resolve({ url: "https://cdn/x.png", ext: "png" }); + }, + }; + const out = await runComponentImport("FILE:1-1", { + projectDir: dir, + client: svgFailsPngWorks, + download: () => Promise.resolve(SVG), + }); + expect(out.failedRasterize).toHaveLength(0); + const html = readFileSync(join(dir, out.htmlPath), "utf8"); + expect(html).toContain('src="'); + expect(calls).toBeGreaterThanOrEqual(2); + }); + + it("propagates non-RENDER_FAILED errors instead of skipping", async () => { + const rateLimited: FigmaClient = { + ...client(), + renderNode: () => + Promise.reject(new FigmaClientError("RATE_LIMITED", "figma rate limit hit (429)", 429)), + }; + await expect( + runComponentImport("FILE:1-1", { + projectDir: dir, + client: rateLimited, + download: () => Promise.resolve(SVG), + }), + ).rejects.toThrow(/rate limit/); + }); }); diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts index d99b805be..283e6135d 100644 --- a/packages/cli/src/commands/figma/component.ts +++ b/packages/cli/src/commands/figma/component.ts @@ -7,6 +7,7 @@ import { defineCommand } from "citty"; import { createFigmaClient, + FigmaClientError, nodeToHtml, parseFigmaRef, readBindings, @@ -41,6 +42,8 @@ export interface ComponentImportResult { htmlPath: string; unresolved: BindingSite[]; rasterized: RasterizeRequest[]; + /** node ids figma refused to render as svg AND png — placeholders shipped without src */ + failedRasterize: string[]; } export async function runComponentImport( @@ -68,12 +71,33 @@ export async function runComponentImport( // replaceAll covers the same node appearing twice in the tree. let html = mapped.html; const frozenAssets: string[] = []; + const failedRasterize: string[] = []; for (const req of mapped.rasterize) { - const asset = await runAssetImport( - `${ref.fileKey}:${req.nodeId}`, - { format: "svg", description: req.name }, - { projectDir: deps.projectDir, client: deps.client, download: deps.download }, - ); + // figma sometimes refuses to render a node (nested instances commonly + // fail as svg) — retry once as png, then skip THIS node and keep the + // import: one unrenderable node must not abort the whole component. The + // placeholder keeps its data-figma-rasterize marker (no src) so the gap + // is visible and hand-fixable. + let asset = null; + for (const format of ["svg", "png"] as const) { + try { + asset = await runAssetImport( + `${ref.fileKey}:${req.nodeId}`, + { format, description: req.name }, + { projectDir: deps.projectDir, client: deps.client, download: deps.download }, + ); + break; + } catch (err) { + if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err; + } + } + if (asset === null) { + failedRasterize.push(req.nodeId); + console.warn( + `could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`, + ); + continue; + } frozenAssets.push(asset.record.path); // src is a URL — always forward slashes, even when relative() yields // windows separators. @@ -120,6 +144,7 @@ export async function runComponentImport( htmlPath: relative(deps.projectDir, htmlFile), unresolved: bindings.unresolved, rasterized: mapped.rasterize, + failedRasterize, }; } @@ -139,8 +164,14 @@ export default defineCommand({ download: downloadRender, }); console.log(`imported component "${result.name}" → ${result.htmlPath}`); - if (result.rasterized.length > 0) - console.log(`rasterized ${result.rasterized.length} node(s) via asset export`); + if (result.rasterized.length > 0) { + const ok = result.rasterized.length - result.failedRasterize.length; + console.log( + result.failedRasterize.length > 0 + ? `rasterized ${ok}/${result.rasterized.length} node(s) via asset export — ${result.failedRasterize.length} skipped (unrenderable; placeholders have no src)` + : `rasterized ${result.rasterized.length} node(s) via asset export`, + ); + } if (result.unresolved.length > 0) { console.log( `${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`, @@ -151,6 +182,7 @@ export default defineCommand({ phase: "component", unresolvedBindings: result.unresolved.length, rasterizedNodes: result.rasterized.length, + rasterizeFailures: result.failedRasterize.length, durationMs: Date.now() - t0, }); }); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index eec26da9c..bdf6e59f6 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -443,6 +443,7 @@ export function trackFigmaImport(props: { entryCount?: number; unresolvedBindings?: number; rasterizedNodes?: number; + rasterizeFailures?: number; }): void { trackEvent("figma_import", { phase: props.phase, @@ -454,6 +455,9 @@ export function trackFigmaImport(props: { ? { unresolved_bindings: props.unresolvedBindings } : {}), ...(props.rasterizedNodes !== undefined ? { rasterized_nodes: props.rasterizedNodes } : {}), + ...(props.rasterizeFailures !== undefined + ? { rasterize_failures: props.rasterizeFailures } + : {}), }); }