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
44 changes: 39 additions & 5 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ const WEBGL_CONTEXT_IDS = [
'experimental-webgl2',
'experimental-webgl',
];
let supportedWebglVersions: string[] | undefined;
// WebGL support never changes during a page's lifetime, so each distinct id
// list is probed exactly once and the result is cached forever. This is
// load-bearing on embedded TV boxes: every probe call creates a real WebGL
// context, and those boxes cap live contexts very low. Probing repeatedly (the
// old behavior bypassed the cache for any non-default argument) creates a new
// context each time, blows the page's context budget, and evicts the oldest
// context — the live render context — which then fails every createTexture.
const supportedWebglVersions = new Map<string, string[]>();

/**
* Converts a color string to a color number value.
Expand Down Expand Up @@ -90,14 +97,26 @@ export function mod(n: number, m: number): number {
export function getWebglSupportedVersions(
webglContextIds: string[] = WEBGL_CONTEXT_IDS,
): string[] {
if (supportedWebglVersions && webglContextIds === WEBGL_CONTEXT_IDS) {
return supportedWebglVersions;
// Cache per distinct id list. The previous version only cached the default
// list, so any caller passing its own array re-probed (and re-created a
// context) on every call — see the comment on `supportedWebglVersions`.
const cacheKey = webglContextIds.join('|');
const cached = supportedWebglVersions.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const cv = document.createElement('canvas');
// A canvas locks to the first context type it hands out, so probing several
// ids on one canvas yields at most one live context. Capture it so we can
// release it below.
let probeContext: RenderingContext | null = null;
const supports = webglContextIds.filter((id) => {
try {
const context = cv.getContext(id);
if (context !== null && probeContext === null) {
probeContext = context;
}
return !!(
context &&
(context instanceof WebGLRenderingContext ||
Expand All @@ -110,10 +129,25 @@ export function getWebglSupportedVersions(
}
});

if (webglContextIds === WEBGL_CONTEXT_IDS) {
supportedWebglVersions = supports;
// Free the probe context immediately instead of leaking it until GC. Embedded
// TV browsers cap live WebGL contexts very low; a lingering probe burns one of
// those scarce slots and can trigger "too many active webgl contexts" — which
// evicts the oldest context (potentially the live render context). Guard with
// isContextLost(): if the browser already evicted this probe, loseContext()
// throws INVALID_OPERATION ("context already lost") and spams the console.
const probe = probeContext as RenderingContext | null;
if (
probe !== null &&
'getExtension' in probe &&
(probe as WebGLRenderingContext).isContextLost() === false
) {
(probe as WebGLRenderingContext)
.getExtension('WEBGL_lose_context')
?.loseContext();
}

supportedWebglVersions.set(cacheKey, supports);

return supports;
}

Expand Down
72 changes: 72 additions & 0 deletions tests/webgl-support.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { getWebglSupportedVersions } from '../src/utils.ts';

// jsdom has no real WebGL, so stub the globals the detection branch checks and
// hand back a fake context that reports support + exposes the lifecycle methods
// the probe-cleanup path calls.
function fakeGl(opts: { contextLost?: boolean } = {}) {
return {
getParameter: vi.fn(),
isContextLost: vi.fn(() => opts.contextLost === true),
getExtension: vi.fn((name: string) =>
name === 'WEBGL_lose_context' ? { loseContext: vi.fn() } : null,
),
};
}

function stubCanvas(getContext: (id: string) => unknown) {
vi.stubGlobal('WebGLRenderingContext', class {});
vi.stubGlobal('WebGL2RenderingContext', class {});
const createElement = vi.spyOn(document, 'createElement').mockReturnValue({
getContext: vi.fn(getContext),
} as unknown as HTMLElement);
return createElement;
}

describe('getWebglSupportedVersions', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('releases the probe context instead of leaking a GL slot', () => {
const gl = fakeGl();
// Unique id list per test so the module-level cache never collides across
// tests (vitest runs this file with isolate:false).
stubCanvas((id) => (id === 'webgl-a' ? gl : null));

const versions = getWebglSupportedVersions(['webgl-a']);

expect(versions).toEqual(['webgl-a']);
expect(gl.getExtension).toHaveBeenCalledWith('WEBGL_lose_context');
});

it('creates a probe context at most once across repeated calls', () => {
const gl = fakeGl();
const createElement = stubCanvas((id) => (id === 'webgl-b' ? gl : null));

getWebglSupportedVersions(['webgl-b']);
getWebglSupportedVersions(['webgl-b']);
getWebglSupportedVersions(['webgl-b']);

// Only the first call probes; the rest hit the cache and create nothing.
expect(createElement).toHaveBeenCalledTimes(1);
});

it('does not call loseContext when the probe context is already lost', () => {
const gl = fakeGl({ contextLost: true });
const ext = { loseContext: vi.fn() };
gl.getExtension = vi.fn(() => ext);
stubCanvas((id) => (id === 'webgl-c' ? gl : null));

getWebglSupportedVersions(['webgl-c']);

expect(ext.loseContext).not.toHaveBeenCalled();
});

it('does not throw when no WebGL context is available', () => {
stubCanvas(() => null);
expect(() => getWebglSupportedVersions(['webgl-d'])).not.toThrow();
expect(getWebglSupportedVersions(['webgl-d'])).toEqual([]);
});
});
Loading