Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/core/src/runtime/adapters/_readiness.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
100 changes: 100 additions & 0 deletions packages/core/src/runtime/adapters/d3.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions packages/core/src/runtime/adapters/d3.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
}
111 changes: 111 additions & 0 deletions packages/core/src/runtime/adapters/google-maps.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 23 additions & 0 deletions packages/core/src/runtime/adapters/google-maps.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}),
});
}
93 changes: 93 additions & 0 deletions packages/core/src/runtime/adapters/leaflet.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading