Skip to content

Commit c43e0ad

Browse files
authored
fix(snapshot): inline blob assets via XHR fallback for WebKit CodePen export (#53)
1 parent a8adc6b commit c43e0ad

2 files changed

Lines changed: 77 additions & 10 deletions

File tree

packages/polycss/src/snapshot/exportPolySceneSnapshot.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,36 @@ describe("exportPolySceneSnapshot", () => {
142142
expect(fetchMock).toHaveBeenCalledTimes(1);
143143
});
144144

145+
it("falls back to XHR when fetch() fails for a blob URL (WebKit)", async () => {
146+
const fetchMock = vi.fn(async () => {
147+
throw new TypeError("Load failed");
148+
});
149+
vi.stubGlobal("fetch", fetchMock);
150+
class FakeXHR {
151+
status = 200;
152+
response: Blob | null = null;
153+
responseType = "";
154+
onload: (() => void) | null = null;
155+
onerror: (() => void) | null = null;
156+
open(): void {}
157+
send(): void {
158+
this.response = new Blob(["atlas"], { type: "image/png" });
159+
this.onload?.();
160+
}
161+
}
162+
vi.stubGlobal("XMLHttpRequest", FakeXHR);
163+
164+
const { leaf } = makeRenderedScene(
165+
'background: url("blob:atlas") 0 0 / 64px 64px no-repeat; --polycss-atlas-size: 64px;',
166+
);
167+
168+
const html = await exportPolySceneSnapshot(leaf);
169+
170+
expect(html).toContain("data:image/png;base64,YXRsYXM=");
171+
expect(html).not.toContain("blob:atlas");
172+
expect(fetchMock).toHaveBeenCalledTimes(1);
173+
});
174+
145175
it("does not fetch already-inline data URLs", async () => {
146176
const fetchMock = vi.fn();
147177
vi.stubGlobal("fetch", fetchMock);

packages/polycss/src/snapshot/exportPolySceneSnapshot.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,52 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
655655
return btoa(binary);
656656
}
657657

658+
function isObjectUrl(url: string): boolean {
659+
return /^(blob|filesystem):/i.test(url.trim());
660+
}
661+
662+
function loadBlobViaXhr(url: string): Promise<Blob> {
663+
return new Promise((resolve, reject) => {
664+
const xhr = new XMLHttpRequest();
665+
xhr.open("GET", url, true);
666+
xhr.responseType = "blob";
667+
xhr.onload = () => {
668+
// Object URLs report status 0 on success; treat that as OK.
669+
if (xhr.status === 0 || (xhr.status >= 200 && xhr.status < 300)) {
670+
resolve(xhr.response as Blob);
671+
} else {
672+
reject(new Error(`XHR ${xhr.status}`));
673+
}
674+
};
675+
xhr.onerror = () => reject(new Error("XHR request failed"));
676+
xhr.send();
677+
});
678+
}
679+
680+
async function loadAssetBlob(url: string): Promise<Blob> {
681+
// Object URLs (blob:/filesystem:) are same-document; a credentials mode is
682+
// meaningless for them and trips some WebKit builds, so omit it there.
683+
const objectUrl = isObjectUrl(url);
684+
try {
685+
if (typeof fetch !== "function") {
686+
throw new Error("fetch is not available");
687+
}
688+
const response = await fetch(url, objectUrl ? undefined : { credentials: "same-origin" });
689+
if (!response.ok) {
690+
throw new Error(`HTTP ${response.status}`);
691+
}
692+
return await response.blob();
693+
} catch (error) {
694+
// WebKit/Safari intermittently fails to fetch() object URLs ("Load
695+
// failed"); XMLHttpRequest is the reliable fallback for same-document
696+
// object URLs and is why a textured/atlas export breaks only on Safari.
697+
if (objectUrl && typeof XMLHttpRequest === "function") {
698+
return loadBlobViaXhr(url);
699+
}
700+
throw error;
701+
}
702+
}
703+
658704
async function inlineAssetUrl(rawUrl: string, ctx: InlineContext): Promise<string> {
659705
if (isInlineOrLocalReference(rawUrl)) return rawUrl;
660706

@@ -664,16 +710,7 @@ async function inlineAssetUrl(rawUrl: string, ctx: InlineContext): Promise<strin
664710

665711
const next = (async () => {
666712
try {
667-
if (typeof fetch !== "function") {
668-
throw new Error("fetch is not available");
669-
}
670-
671-
const response = await fetch(resolvedUrl, { credentials: "same-origin" });
672-
if (!response.ok) {
673-
throw new Error(`HTTP ${response.status}`);
674-
}
675-
676-
const blob = await response.blob();
713+
const blob = await loadAssetBlob(resolvedUrl);
677714
const mime = blob.type || inferMimeType(resolvedUrl);
678715
const base64 = arrayBufferToBase64(await blob.arrayBuffer());
679716
return `data:${mime};base64,${base64}`;

0 commit comments

Comments
 (0)