feat(core): export runtime start resolver with documentRef parameter

This commit is contained in:
Vance Ingalls
2026-07-16 11:01:27 -07:00
parent 3bb26b0f08
commit fd19a1563d
3 changed files with 63 additions and 4 deletions
+10
View File
@@ -113,6 +113,12 @@
"import": "./src/runtime/startExpression.ts",
"types": "./src/runtime/startExpression.ts"
},
"./runtime/start-resolver": {
"bun": "./src/runtime/startResolver.ts",
"node": "./dist/runtime/startResolver.js",
"import": "./src/runtime/startResolver.ts",
"types": "./src/runtime/startResolver.ts"
},
"./runtime/protocol": {
"bun": "./src/runtime/protocol.ts",
"node": "./dist/runtime/protocol.js",
@@ -329,6 +335,10 @@
"import": "./dist/runtime/startExpression.js",
"types": "./dist/runtime/startExpression.d.ts"
},
"./runtime/start-resolver": {
"import": "./dist/runtime/startResolver.js",
"types": "./dist/runtime/startResolver.d.ts"
},
"./runtime/protocol": {
"import": "./dist/runtime/protocol.js",
"types": "./dist/runtime/protocol.d.ts"
@@ -399,3 +399,35 @@ describe("createRuntimeStartTimeResolver", () => {
});
});
});
describe("documentRef", () => {
it("resolves references against a supplied document instead of the global one", () => {
const doc = document.implementation.createHTMLDocument("t");
const intro = doc.createElement("div");
intro.id = "intro";
intro.setAttribute("data-start", "0");
intro.setAttribute("data-duration", "4");
doc.body.appendChild(intro);
const outro = doc.createElement("div");
outro.setAttribute("data-start", "intro + 2");
doc.body.appendChild(outro);
const resolver = createRuntimeStartTimeResolver({ documentRef: doc });
// intro ends at 0 + 4; outro starts 2s after → 6.
expect(resolver.resolveStartForElement(outro)).toBe(6);
});
it("defaults to the global document when documentRef is omitted", () => {
const el = document.createElement("div");
el.id = "ref-a";
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", "2");
document.body.appendChild(el);
const dependent = document.createElement("div");
dependent.setAttribute("data-start", "ref-a");
document.body.appendChild(dependent);
const resolver = createRuntimeStartTimeResolver({});
expect(resolver.resolveStartForElement(dependent)).toBe(2);
});
});
+21 -4
View File
@@ -25,25 +25,40 @@ function parseAuthoredEndAttr(element: Element): number | null {
export function createRuntimeStartTimeResolver(params: {
timelineRegistry?: Record<string, RuntimeTimelineLike | undefined>;
includeAuthoredTimingAttrs?: boolean;
/**
* The document that reference lookups (`data-start="intro + 2"`) resolve
* against. Defaults to the global `document` — the runtime bundle's own
* realm. Hosts driving a composition in an IFRAME must pass that iframe's
* document, or every reference silently resolves against the host page.
*/
documentRef?: Document;
}): {
resolveStartForElement: (element: Element, fallback?: number) => number;
resolveDurationForElement: (element: Element) => number | null;
} {
const timelineRegistry = params.timelineRegistry ?? {};
const includeAuthoredTimingAttrs = params.includeAuthoredTimingAttrs ?? false;
const doc = params.documentRef ?? document;
const startCache = new WeakMap<Element, number | null>();
const durationCache = new WeakMap<Element, number | null>();
const visiting = new Set<Element>();
const findReferenceTarget = (refId: string): Element | null => {
const byId = document.getElementById(refId);
const byId = doc.getElementById(refId);
if (byId) return byId;
return (
(document.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ??
null
(doc.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ?? null
);
};
// Realm-safe: an iframe document's media elements are instances of THAT
// frame's HTMLMediaElement, never this module's global one.
const isMediaElement = (el: Element): el is HTMLMediaElement => {
const RealmMedia = el.ownerDocument.defaultView?.HTMLMediaElement;
if (RealmMedia) return el instanceof RealmMedia;
return typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement;
};
const resolveDurationForElement = (element: Element): number | null => {
const cached = durationCache.get(element);
if (cached !== undefined) return cached;
@@ -66,7 +81,7 @@ export function createRuntimeStartTimeResolver(params: {
}
}
}
if ((resolved == null || resolved <= 0) && element instanceof HTMLMediaElement) {
if ((resolved == null || resolved <= 0) && isMediaElement(element)) {
const playbackStart =
parseNumeric(element.getAttribute("data-playback-start")) ??
parseNumeric(element.getAttribute("data-media-start")) ??
@@ -181,3 +196,5 @@ export function createRuntimeStartTimeResolver(params: {
resolveDurationForElement: (element: Element) => resolveDurationForElement(element),
};
}
export type { RuntimeTimelineLike } from "./types";