mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core): add readiness adapters for map and visualization libraries (#1548)
Add readiness-only runtime adapters for Mapbox GL JS, Leaflet, Google Maps, MapLibre GL JS, and D3. Each adapter gates `__renderReady` until the library's async initialization completes, preventing the renderer from capturing blank or half-loaded frames. Built on the `getReadyPromise` adapter contract from #1543. A shared `createReadinessAdapter()` helper in `_readiness.ts` owns the settled-tracking WeakSet, promise-identity stability, and `Promise.allSettled` gate — each adapter provides only its type, window global name, and `waitFor` callback. Readiness signals per library: - Mapbox / MapLibre: `map.loaded()` + `map.on('load', ...)` - Leaflet: `map.whenReady(cb)` - Google Maps: `map.addListener('tilesloaded', cb)` with handle cleanup - D3: `transition.end()` promise 50 unit tests across 5 test files covering happy path, no-instances, stable promise identity, post-settle drain, loaded-before-subscribe race, and listener cleanup. 5 producer regression tests with Docker-generated baselines for end-to-end render verification.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import type { RuntimeDeterministicAdapter } from "../types";
|
||||
|
||||
export function createReadinessAdapter<T extends object>(opts: {
|
||||
name: string;
|
||||
getInstances: () => T[];
|
||||
waitFor: (instance: T) => PromiseLike<void>;
|
||||
}): RuntimeDeterministicAdapter {
|
||||
let pendingPromise: PromiseLike<void> | null = null;
|
||||
const settled = new WeakSet<T>();
|
||||
|
||||
return {
|
||||
name: opts.name,
|
||||
discover: () => {},
|
||||
seek: () => {},
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
revert: () => {},
|
||||
getReadyPromise: () => {
|
||||
const instances = opts.getInstances();
|
||||
if (instances.length === 0) return null;
|
||||
const unsettled = instances.filter((i) => !settled.has(i));
|
||||
if (unsettled.length === 0) return null;
|
||||
if (pendingPromise) return pendingPromise;
|
||||
pendingPromise = Promise.allSettled(
|
||||
unsettled.map((i) =>
|
||||
opts.waitFor(i).then(() => {
|
||||
settled.add(i);
|
||||
}),
|
||||
),
|
||||
).then(() => {
|
||||
pendingPromise = null;
|
||||
});
|
||||
return pendingPromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createD3Adapter } from "./d3";
|
||||
|
||||
type D3TransitionLike = {
|
||||
end: () => PromiseLike<void>;
|
||||
};
|
||||
|
||||
const d3Window = window as Window & { __hfD3?: D3TransitionLike[] };
|
||||
|
||||
function createMockTransition(opts?: { resolved?: boolean }): D3TransitionLike {
|
||||
let resolver: (() => void) | null = null;
|
||||
return {
|
||||
end: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (opts?.resolved) {
|
||||
resolve();
|
||||
} else {
|
||||
resolver = resolve;
|
||||
}
|
||||
}),
|
||||
),
|
||||
_resolve() {
|
||||
resolver?.();
|
||||
},
|
||||
} as D3TransitionLike & { _resolve: () => void };
|
||||
}
|
||||
|
||||
describe("d3 adapter", () => {
|
||||
beforeEach(() => {
|
||||
delete d3Window.__hfD3;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete d3Window.__hfD3;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createD3Adapter().name).toBe("d3");
|
||||
});
|
||||
|
||||
describe("getReadyPromise", () => {
|
||||
it("returns null when no transitions registered", () => {
|
||||
const adapter = createD3Adapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when __hfD3 is empty", () => {
|
||||
d3Window.__hfD3 = [];
|
||||
const adapter = createD3Adapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves when transition ends", async () => {
|
||||
const t = createMockTransition() as D3TransitionLike & { _resolve: () => void };
|
||||
d3Window.__hfD3 = [t];
|
||||
const adapter = createD3Adapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
t._resolve();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("resolves immediately for already-resolved transition", async () => {
|
||||
const t = createMockTransition({ resolved: true });
|
||||
d3Window.__hfD3 = [t];
|
||||
const adapter = createD3Adapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("returns same promise on repeated calls (stable identity)", () => {
|
||||
const t = createMockTransition();
|
||||
d3Window.__hfD3 = [t];
|
||||
const adapter = createD3Adapter();
|
||||
const p1 = adapter.getReadyPromise!();
|
||||
const p2 = adapter.getReadyPromise!();
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
it("returns null after all transitions have settled", async () => {
|
||||
const t = createMockTransition({ resolved: true });
|
||||
d3Window.__hfD3 = [t];
|
||||
const adapter = createD3Adapter();
|
||||
await adapter.getReadyPromise!();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
const adapter = createD3Adapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("seek is a no-op", () => {
|
||||
const adapter = createD3Adapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createReadinessAdapter } from "./_readiness";
|
||||
|
||||
type D3TransitionLike = {
|
||||
end: () => PromiseLike<void>;
|
||||
};
|
||||
|
||||
export function createD3Adapter() {
|
||||
return createReadinessAdapter<D3TransitionLike>({
|
||||
name: "d3",
|
||||
getInstances: () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const arr = (window as { __hfD3?: D3TransitionLike[] }).__hfD3;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
},
|
||||
waitFor: (t) => t.end(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createGoogleMapsAdapter } from "./google-maps";
|
||||
|
||||
type GoogleMapLike = {
|
||||
addListener: (event: string, cb: () => void) => { remove: () => void };
|
||||
};
|
||||
|
||||
const mapWindow = window as Window & { __hfGoogleMaps?: GoogleMapLike[] };
|
||||
|
||||
function createMockMap(): GoogleMapLike & { _fire: (e: string) => void } {
|
||||
const listeners: Record<string, { cb: () => void; handle: { remove: () => void } }[]> = {};
|
||||
return {
|
||||
addListener: vi.fn((event: string, cb: () => void) => {
|
||||
const entry = { cb, handle: { remove: vi.fn() } };
|
||||
(listeners[event] ??= []).push(entry);
|
||||
return entry.handle;
|
||||
}),
|
||||
_fire(event: string) {
|
||||
for (const entry of listeners[event] ?? []) entry.cb();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("google-maps adapter", () => {
|
||||
beforeEach(() => {
|
||||
delete mapWindow.__hfGoogleMaps;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete mapWindow.__hfGoogleMaps;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createGoogleMapsAdapter().name).toBe("google-maps");
|
||||
});
|
||||
|
||||
describe("getReadyPromise", () => {
|
||||
it("returns null when no maps registered", () => {
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when __hfGoogleMaps is empty", () => {
|
||||
mapWindow.__hfGoogleMaps = [];
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves when map fires tilesloaded", async () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfGoogleMaps = [map];
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
map._fire("tilesloaded");
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("returns same promise on repeated calls (stable identity)", () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfGoogleMaps = [map];
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
const p1 = adapter.getReadyPromise!();
|
||||
const p2 = adapter.getReadyPromise!();
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
it("returns null after all maps have settled", async () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfGoogleMaps = [map];
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
map._fire("tilesloaded");
|
||||
await promise;
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("removes listener after first tilesloaded fire", async () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfGoogleMaps = [map];
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
map._fire("tilesloaded");
|
||||
await promise;
|
||||
const handle = (map.addListener as ReturnType<typeof vi.fn>).mock.results[0].value;
|
||||
expect(handle.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles multiple maps", async () => {
|
||||
const map1 = createMockMap();
|
||||
const map2 = createMockMap();
|
||||
mapWindow.__hfGoogleMaps = [map1, map2];
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
map1._fire("tilesloaded");
|
||||
map2._fire("tilesloaded");
|
||||
await promise;
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("seek is a no-op", () => {
|
||||
const adapter = createGoogleMapsAdapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createReadinessAdapter } from "./_readiness";
|
||||
|
||||
type GoogleMapLike = {
|
||||
addListener: (event: string, cb: () => void) => { remove: () => void };
|
||||
};
|
||||
|
||||
export function createGoogleMapsAdapter() {
|
||||
return createReadinessAdapter<GoogleMapLike>({
|
||||
name: "google-maps",
|
||||
getInstances: () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const arr = (window as { __hfGoogleMaps?: GoogleMapLike[] }).__hfGoogleMaps;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
},
|
||||
waitFor: (m) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const handle = m.addListener("tilesloaded", () => {
|
||||
handle.remove();
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createLeafletAdapter } from "./leaflet";
|
||||
|
||||
type LeafletMapLike = {
|
||||
whenReady: (cb: () => void) => void;
|
||||
};
|
||||
|
||||
const mapWindow = window as Window & { __hfLeaflet?: LeafletMapLike[] };
|
||||
|
||||
function createMockMap(opts?: { ready?: boolean }): LeafletMapLike {
|
||||
return {
|
||||
whenReady: vi.fn((cb: () => void) => {
|
||||
if (opts?.ready ?? false) cb();
|
||||
}),
|
||||
_fireReady() {
|
||||
const calls = (this.whenReady as ReturnType<typeof vi.fn>).mock.calls;
|
||||
for (const [cb] of calls) cb();
|
||||
},
|
||||
} as LeafletMapLike & { _fireReady: () => void };
|
||||
}
|
||||
|
||||
describe("leaflet adapter", () => {
|
||||
beforeEach(() => {
|
||||
delete mapWindow.__hfLeaflet;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete mapWindow.__hfLeaflet;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createLeafletAdapter().name).toBe("leaflet");
|
||||
});
|
||||
|
||||
describe("getReadyPromise", () => {
|
||||
it("returns null when no maps registered", () => {
|
||||
const adapter = createLeafletAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when __hfLeaflet is empty", () => {
|
||||
mapWindow.__hfLeaflet = [];
|
||||
const adapter = createLeafletAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves when map fires whenReady callback", async () => {
|
||||
const map = createMockMap() as LeafletMapLike & { _fireReady: () => void };
|
||||
mapWindow.__hfLeaflet = [map];
|
||||
const adapter = createLeafletAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
map._fireReady();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("resolves immediately for already-ready map", async () => {
|
||||
const map = createMockMap({ ready: true });
|
||||
mapWindow.__hfLeaflet = [map];
|
||||
const adapter = createLeafletAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("returns same promise on repeated calls (stable identity)", () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfLeaflet = [map];
|
||||
const adapter = createLeafletAdapter();
|
||||
const p1 = adapter.getReadyPromise!();
|
||||
const p2 = adapter.getReadyPromise!();
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
it("returns null after all maps have settled", async () => {
|
||||
const map = createMockMap({ ready: true });
|
||||
mapWindow.__hfLeaflet = [map];
|
||||
const adapter = createLeafletAdapter();
|
||||
await adapter.getReadyPromise!();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
const adapter = createLeafletAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("seek is a no-op", () => {
|
||||
const adapter = createLeafletAdapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createReadinessAdapter } from "./_readiness";
|
||||
|
||||
type LeafletMapLike = {
|
||||
whenReady: (cb: () => void) => void;
|
||||
};
|
||||
|
||||
export function createLeafletAdapter() {
|
||||
return createReadinessAdapter<LeafletMapLike>({
|
||||
name: "leaflet",
|
||||
getInstances: () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const arr = (window as { __hfLeaflet?: LeafletMapLike[] }).__hfLeaflet;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
},
|
||||
waitFor: (m) => new Promise<void>((resolve) => m.whenReady(resolve)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createMapboxAdapter } from "./mapbox";
|
||||
|
||||
type MapboxMapLike = {
|
||||
loaded: () => boolean;
|
||||
on: (event: string, cb: () => void) => void;
|
||||
};
|
||||
|
||||
const mapWindow = window as Window & { __hfMapbox?: MapboxMapLike[] };
|
||||
|
||||
function createMockMap(opts?: { loaded?: boolean }): MapboxMapLike {
|
||||
const listeners: Record<string, (() => void)[]> = {};
|
||||
return {
|
||||
loaded: vi.fn(() => opts?.loaded ?? false),
|
||||
on: vi.fn((event: string, cb: () => void) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
}),
|
||||
_fire(event: string) {
|
||||
for (const cb of listeners[event] ?? []) cb();
|
||||
},
|
||||
} as MapboxMapLike & { _fire: (e: string) => void };
|
||||
}
|
||||
|
||||
describe("mapbox adapter", () => {
|
||||
beforeEach(() => {
|
||||
delete mapWindow.__hfMapbox;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete mapWindow.__hfMapbox;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createMapboxAdapter().name).toBe("mapbox");
|
||||
});
|
||||
|
||||
describe("getReadyPromise", () => {
|
||||
it("returns null when no maps registered", () => {
|
||||
const adapter = createMapboxAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when __hfMapbox is empty", () => {
|
||||
mapWindow.__hfMapbox = [];
|
||||
const adapter = createMapboxAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves when map fires load event", async () => {
|
||||
const map = createMockMap() as MapboxMapLike & { _fire: (e: string) => void };
|
||||
mapWindow.__hfMapbox = [map];
|
||||
const adapter = createMapboxAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
map._fire("load");
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("resolves immediately for already-loaded map", async () => {
|
||||
const map = createMockMap({ loaded: true });
|
||||
mapWindow.__hfMapbox = [map];
|
||||
const adapter = createMapboxAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("returns same promise on repeated calls (stable identity)", () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfMapbox = [map];
|
||||
const adapter = createMapboxAdapter();
|
||||
const p1 = adapter.getReadyPromise!();
|
||||
const p2 = adapter.getReadyPromise!();
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
it("returns null after all maps have settled", async () => {
|
||||
const map = createMockMap({ loaded: true });
|
||||
mapWindow.__hfMapbox = [map];
|
||||
const adapter = createMapboxAdapter();
|
||||
await adapter.getReadyPromise!();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("handles mix of loaded and unloaded maps", async () => {
|
||||
const loaded = createMockMap({ loaded: true });
|
||||
const unloaded = createMockMap() as MapboxMapLike & { _fire: (e: string) => void };
|
||||
mapWindow.__hfMapbox = [loaded, unloaded];
|
||||
const adapter = createMapboxAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
unloaded._fire("load");
|
||||
await promise;
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves via loaded() check when map finishes loading before subscribe", async () => {
|
||||
const racyMap = {
|
||||
loaded: vi.fn(() => true),
|
||||
on: vi.fn(),
|
||||
};
|
||||
mapWindow.__hfMapbox = [racyMap];
|
||||
const adapter = createMapboxAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
expect(racyMap.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
const adapter = createMapboxAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("seek is a no-op", () => {
|
||||
const adapter = createMapboxAdapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createReadinessAdapter } from "./_readiness";
|
||||
|
||||
type MapboxMapLike = {
|
||||
loaded: () => boolean;
|
||||
on: (event: string, cb: () => void) => void;
|
||||
};
|
||||
|
||||
// 'load' = style + sources ready (not tile-level 'idle'); sufficient for render-ready gate
|
||||
export function createMapboxAdapter() {
|
||||
return createReadinessAdapter<MapboxMapLike>({
|
||||
name: "mapbox",
|
||||
getInstances: () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const arr = (window as { __hfMapbox?: MapboxMapLike[] }).__hfMapbox;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
},
|
||||
waitFor: (m) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (m.loaded()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
m.on("load", resolve);
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createMaplibreAdapter } from "./maplibre";
|
||||
|
||||
type MaplibreMapLike = {
|
||||
loaded: () => boolean;
|
||||
on: (event: string, cb: () => void) => void;
|
||||
};
|
||||
|
||||
const mapWindow = window as Window & { __hfMaplibre?: MaplibreMapLike[] };
|
||||
|
||||
function createMockMap(opts?: {
|
||||
loaded?: boolean;
|
||||
}): MaplibreMapLike & { _fire: (e: string) => void } {
|
||||
const listeners: Record<string, (() => void)[]> = {};
|
||||
return {
|
||||
loaded: vi.fn(() => opts?.loaded ?? false),
|
||||
on: vi.fn((event: string, cb: () => void) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
}),
|
||||
_fire(event: string) {
|
||||
for (const cb of listeners[event] ?? []) cb();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("maplibre adapter", () => {
|
||||
beforeEach(() => {
|
||||
delete mapWindow.__hfMaplibre;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete mapWindow.__hfMaplibre;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createMaplibreAdapter().name).toBe("maplibre");
|
||||
});
|
||||
|
||||
describe("getReadyPromise", () => {
|
||||
it("returns null when no maps registered", () => {
|
||||
const adapter = createMaplibreAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when __hfMaplibre is empty", () => {
|
||||
mapWindow.__hfMaplibre = [];
|
||||
const adapter = createMaplibreAdapter();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves when map fires load event", async () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfMaplibre = [map];
|
||||
const adapter = createMaplibreAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
map._fire("load");
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("resolves immediately for already-loaded map", async () => {
|
||||
const map = createMockMap({ loaded: true });
|
||||
mapWindow.__hfMaplibre = [map];
|
||||
const adapter = createMaplibreAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("returns same promise on repeated calls (stable identity)", () => {
|
||||
const map = createMockMap();
|
||||
mapWindow.__hfMaplibre = [map];
|
||||
const adapter = createMaplibreAdapter();
|
||||
const p1 = adapter.getReadyPromise!();
|
||||
const p2 = adapter.getReadyPromise!();
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
it("returns null after all maps have settled", async () => {
|
||||
const map = createMockMap({ loaded: true });
|
||||
mapWindow.__hfMaplibre = [map];
|
||||
const adapter = createMaplibreAdapter();
|
||||
await adapter.getReadyPromise!();
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("handles mix of loaded and unloaded maps", async () => {
|
||||
const loaded = createMockMap({ loaded: true });
|
||||
const unloaded = createMockMap();
|
||||
mapWindow.__hfMaplibre = [loaded, unloaded];
|
||||
const adapter = createMaplibreAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
unloaded._fire("load");
|
||||
await promise;
|
||||
expect(adapter.getReadyPromise!()).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves via loaded() check when map finishes loading before subscribe", async () => {
|
||||
const racyMap = {
|
||||
loaded: vi.fn(() => true),
|
||||
on: vi.fn(),
|
||||
};
|
||||
mapWindow.__hfMaplibre = [racyMap];
|
||||
const adapter = createMaplibreAdapter();
|
||||
const promise = adapter.getReadyPromise!();
|
||||
expect(promise).not.toBeNull();
|
||||
await promise;
|
||||
expect(racyMap.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
const adapter = createMaplibreAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("seek is a no-op", () => {
|
||||
const adapter = createMaplibreAdapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createReadinessAdapter } from "./_readiness";
|
||||
|
||||
type MaplibreMapLike = {
|
||||
loaded: () => boolean;
|
||||
on: (event: string, cb: () => void) => void;
|
||||
};
|
||||
|
||||
// 'load' = style + sources ready; same contract as Mapbox GL JS
|
||||
export function createMaplibreAdapter() {
|
||||
return createReadinessAdapter<MaplibreMapLike>({
|
||||
name: "maplibre",
|
||||
getInstances: () => {
|
||||
if (typeof window === "undefined") return [];
|
||||
const arr = (window as { __hfMaplibre?: MaplibreMapLike[] }).__hfMaplibre;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
},
|
||||
waitFor: (m) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (m.loaded()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
m.on("load", resolve);
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { createGsapAdapter } from "./adapters/gsap";
|
||||
import { createAnimeJsAdapter } from "./adapters/animejs";
|
||||
import { createLottieAdapter } from "./adapters/lottie";
|
||||
import { createThreeAdapter } from "./adapters/three";
|
||||
import { createMapboxAdapter } from "./adapters/mapbox";
|
||||
import { createLeafletAdapter } from "./adapters/leaflet";
|
||||
import { createGoogleMapsAdapter } from "./adapters/google-maps";
|
||||
import { createMaplibreAdapter } from "./adapters/maplibre";
|
||||
import { createD3Adapter } from "./adapters/d3";
|
||||
import { createTypegpuAdapter } from "./adapters/typegpu";
|
||||
import {
|
||||
patchVideoTextureCompat,
|
||||
@@ -1913,6 +1918,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
createAnimeJsAdapter(),
|
||||
createLottieAdapter(),
|
||||
createThreeAdapter(),
|
||||
createMapboxAdapter(),
|
||||
createLeafletAdapter(),
|
||||
createGoogleMapsAdapter(),
|
||||
createMaplibreAdapter(),
|
||||
createD3Adapter(),
|
||||
createTypegpuAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
] as RuntimeDeterministicAdapter[];
|
||||
|
||||
+30
@@ -102,6 +102,36 @@ declare global {
|
||||
* window.__hfLottie.push(anim);
|
||||
*/
|
||||
__hfLottie?: unknown[];
|
||||
/**
|
||||
* Mapbox GL JS map instances. Push your map here after creating it:
|
||||
* window.__hfMapbox = window.__hfMapbox || [];
|
||||
* window.__hfMapbox.push(map);
|
||||
*/
|
||||
__hfMapbox?: unknown[];
|
||||
/**
|
||||
* Leaflet map instances. Push your map here after creating it:
|
||||
* window.__hfLeaflet = window.__hfLeaflet || [];
|
||||
* window.__hfLeaflet.push(map);
|
||||
*/
|
||||
__hfLeaflet?: unknown[];
|
||||
/**
|
||||
* Google Maps instances. Push your map here after creating it:
|
||||
* window.__hfGoogleMaps = window.__hfGoogleMaps || [];
|
||||
* window.__hfGoogleMaps.push(map);
|
||||
*/
|
||||
__hfGoogleMaps?: unknown[];
|
||||
/**
|
||||
* MapLibre GL JS map instances. Push your map here after creating it:
|
||||
* window.__hfMaplibre = window.__hfMaplibre || [];
|
||||
* window.__hfMaplibre.push(map);
|
||||
*/
|
||||
__hfMaplibre?: unknown[];
|
||||
/**
|
||||
* D3 transition instances. Push your transition here after creating it:
|
||||
* window.__hfD3 = window.__hfD3 || [];
|
||||
* window.__hfD3.push(transition);
|
||||
*/
|
||||
__hfD3?: unknown[];
|
||||
/**
|
||||
* Render-time variable overrides injected by the engine when the user
|
||||
* passes `hyperframes render --variables '<json>'`. Read indirectly via
|
||||
|
||||
Reference in New Issue
Block a user