From 2a0bd153fce8686b17cebc4102bcbaeb24c4d0b2 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 11 May 2026 14:18:38 +0200 Subject: [PATCH 01/12] feat(dom): aligned with v2 fixes --- src/core/dom-renderer/domRenderer.ts | 414 ++++++++++++++++++++------- 1 file changed, 318 insertions(+), 96 deletions(-) diff --git a/src/core/dom-renderer/domRenderer.ts b/src/core/dom-renderer/domRenderer.ts index 066051d..419d62e 100644 --- a/src/core/dom-renderer/domRenderer.ts +++ b/src/core/dom-renderer/domRenderer.ts @@ -49,7 +49,7 @@ const supportsCssMask: boolean = supportsStandardMask || supportsWebkitMask; Animations */ -let animationTasks: AnimationController[] = []; +const animationTasks: AnimationController[] = []; let animationFrameRequested = false; function requestAnimationUpdate() { @@ -67,10 +67,10 @@ function updateAnimations(time: number) { so that the later task will override the earlier ones */ for (let i = 0; i < animationTasks.length; i++) { - let task = animationTasks[i]!; + const task = animationTasks[i]!; if (task.pausedTime != null) continue; - let elapsed = time - task.timeStart; + const elapsed = time - task.timeStart; // Still in delay period if (elapsed < task.settings.delay) { @@ -78,7 +78,7 @@ function updateAnimations(time: number) { continue; } - let activeTime = elapsed - task.settings.delay; + const activeTime = elapsed - task.settings.delay; if (activeTime >= task.settings.duration) { // Start next iteration @@ -106,9 +106,9 @@ function updateAnimations(time: number) { let t = activeTime / task.settings.duration; t = applyEasing(task.settings.easing, t); - for (let prop in task.propsEnd) { - let start = task.propsStart[prop]!; - let end = task.propsEnd[prop]!; + for (const prop in task.propsEnd) { + const start = task.propsStart[prop]!; + const end = task.propsEnd[prop]!; (task.node.props as any)[prop] = interpolateProp(prop, start, end, t); } @@ -150,7 +150,7 @@ class AnimationController implements lng.IAnimationController { this.timeEnd = this.timeStart + this.settings.delay + this.settings.duration; - for (let [prop, value] of Object.entries(props)) { + for (const [prop, value] of Object.entries(props)) { if (value != null && typeof value === 'number') { this.propsStart[prop] = (node.props as any)[prop]; this.propsEnd[prop] = value; @@ -177,7 +177,7 @@ class AnimationController implements lng.IAnimationController { return this; } stop() { - let index = animationTasks.indexOf(this); + const index = animationTasks.indexOf(this); if (index !== -1) { animationTasks.splice(index, 1); } @@ -224,7 +224,7 @@ function animate( Node Properties */ -let elMap = new WeakMap(); +const elMap = new WeakMap(); function updateNodeParent(node: DOMNode | DOMText) { const parent = node.props.parent; @@ -233,53 +233,93 @@ function updateNodeParent(node: DOMNode | DOMText) { } } -function updateNodeStyles(node: DOMNode | DOMText) { - let { props } = node; +/** + * Builds the CSS transform string from node props (x, y, rotation, scale, mount). + * Returns an empty string if no transform is needed. + */ +function buildTransformCSS(props: IRendererNodeProps): string { + const transforms: string[] = []; - let style = `position: absolute; z-index: ${props.zIndex}; opacity: ${props.alpha ?? 1};`; + const { x, y } = props; + const hasMountX = props.mountX != null && props.mountX !== 0; + const hasMountY = props.mountY != null && props.mountY !== 0; - if (props.clipping) { - style += `overflow: hidden;`; - } + if (x !== 0) transforms.push(`translateX(${x}px)`); + if (hasMountX) transforms.push(`translateX(${-props.mountX * 100}%)`); - // Transform - { - let transform = ''; + if (y !== 0) transforms.push(`translateY(${y}px)`); + if (hasMountY) transforms.push(`translateY(${-props.mountY * 100}%)`); - let { x, y } = props; + if (props.rotation !== 0) transforms.push(`rotate(${props.rotation}rad)`); - const hasMountX = props.mountX != null && props.mountX !== 0; - const hasMountY = props.mountY != null && props.mountY !== 0; + if (props.scale !== 1 && props.scale != null) { + transforms.push(`scale(${props.scale})`); + } else { + if (props.scaleX !== 1) transforms.push(`scaleX(${props.scaleX})`); + if (props.scaleY !== 1) transforms.push(`scaleY(${props.scaleY})`); + } - if (x !== 0) transform += `translateX(${x}px)`; - if (hasMountX) transform += `translateX(${-props.mountX! * 100}%)`; + return transforms.join(' '); +} - if (y !== 0) transform += `translateY(${y}px)`; - if (hasMountY) transform += `translateY(${-props.mountY! * 100}%)`; +/** + * Fast path for transform-only updates (x, y, rotation, scale, mount). + * Skips full style rebuild but still re-evaluates viewport bounds for nodes + * with a texture source to drive lazy image load/unload during scroll. + */ +function updateTransformOnly(node: DOMNode | DOMText): void { + const transform = buildTransformCSS(node.props); + const s = node.div.style; + + if (transform.length > 0) { + s.transform = `${transform}`; + } else { + s.transform = ''; + } - if (props.rotation !== 0) transform += `rotate(${props.rotation}rad)`; + updateRenderStateIfNeeded(node); +} - if (props.scale !== 1 && props.scale != null) { - transform += `scale(${props.scale})`; - } else { - if (props.scaleX !== 1) transform += `scaleX(${props.scaleX})`; - if (props.scaleY !== 1) transform += `scaleY(${props.scaleY})`; +/** + * Recomputes the viewport bounds state for a node with a texture source and, + * if changed, triggers lazy image load or unload via updateRenderState. + */ +function updateRenderStateIfNeeded(node: DOMNode | DOMText): void { + if (!(node instanceof DOMNode) || node === node.stage.root) return; + const hasTextureSrc = nodeHasTextureSource(node); + if (hasTextureSrc && node.boundsDirty) { + const next = computeRenderStateForNode(node); + if (next != null) { + node.updateRenderState(next); } + node.boundsDirty = false; + } else if (!hasTextureSrc) { + node.boundsDirty = false; + } +} + +function updateNodeStyles(node: DOMNode | DOMText) { + const { props } = node; + + let style = `position: absolute; z-index: ${props.zIndex};`; + + if (props.alpha !== 1) style += `opacity: ${props.alpha};`; + if (props.clipping) { + style += `overflow: hidden;`; + } + + // Transform + { + const transform = buildTransformCSS(props); if (transform.length > 0) { style += `transform: ${transform};`; } - - let pivotX = props.pivotX ?? props.pivot ?? 0.5; - let pivotY = props.pivotY ?? props.pivot ?? 0.5; - if (pivotX !== 0.5 || pivotY !== 0.5) { - style += `transform-origin: ${pivotX * 100}% ${pivotY * 100}%;`; - } } // if (node instanceof DOMText) { - let textProps = node.props; + const textProps = node.props; if (textProps.color != null && textProps.color !== 0) { style += `color: ${colorToRgba(textProps.color)};`; @@ -324,7 +364,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { } break; case 'both': { - let lineHeight = getNodeLineHeight(textProps); + const lineHeight = getNodeLineHeight(textProps); const widthConstraint = textProps.maxWidth && textProps.maxWidth > 0 ? `${textProps.maxWidth}px` @@ -391,17 +431,17 @@ function updateNodeStyles(node: DOMNode | DOMText) { if (props.w !== 0) style += `width: ${props.w < 0 ? 0 : props.w}px;`; if (props.h !== 0) style += `height: ${props.h}px;`; - let vGradient = + const vGradient = props.colorBottom !== props.colorTop ? `linear-gradient(to bottom, ${colorToRgba(props.colorTop)}, ${colorToRgba(props.colorBottom)})` : null; - let hGradient = + const hGradient = props.colorLeft !== props.colorRight ? `linear-gradient(to right, ${colorToRgba(props.colorLeft)}, ${colorToRgba(props.colorRight)})` : null; - let gradient = + const gradient = vGradient && hGradient ? `${vGradient}, ${hGradient}` : vGradient || hGradient; @@ -436,10 +476,9 @@ function updateNodeStyles(node: DOMNode | DOMText) { let imgStyle = ''; let hasDivBgTint = false; + let hasTint = false; if (rawImgSrc) { - needsBackgroundLayer = true; - - const hasTint = props.color !== 0xffffffff && props.color !== 0x00000000; + hasTint = props.color !== 0xffffffff && props.color !== 0x00000000; if (hasTint) { bgStyle += `background-color: ${colorToRgba(props.color)};`; @@ -465,6 +504,8 @@ function updateNodeStyles(node: DOMNode | DOMText) { 'bottom: 0', 'display: block', 'pointer-events: none', + `opacity: ${node.imageLoading ? 0 : 1}`, + 'transition: opacity 100ms linear', ]; if (props.textureOptions.resizeMode?.type) { @@ -497,7 +538,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { if (supportsMixBlendMode) { imgStyleParts.push('mix-blend-mode: multiply'); } else { - imgStyleParts.push('opacity: 0.9'); + imgStyleParts.push('opacity: 1'); } } @@ -511,13 +552,13 @@ function updateNodeStyles(node: DOMNode | DOMText) { } if (props.shader?.props != null) { - let shaderProps = props.shader.props; + const shaderProps = props.shader.props; - let borderWidth = shaderProps['border-w']; - let borderColor = shaderProps['border-color']; - let borderGap = shaderProps['border-gap'] ?? 0; - let borderAlign = shaderProps['border-align'] ?? 'inside'; - let radius = shaderProps['radius']; + const borderWidth = shaderProps['border-w']; + const borderColor = shaderProps['border-color']; + const borderGap = shaderProps['border-gap'] ?? 0; + const borderAlign = shaderProps['border-align'] ?? 'inside'; + const radius = shaderProps['radius']; // Border const borderWidthIsNumber = typeof borderWidth === 'number'; @@ -606,11 +647,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { const rg = shaderProps.radial as | Partial | undefined; - const colors = Array.isArray(rg?.colors) ? rg!.colors! : []; - const stops = Array.isArray(rg?.stops) ? rg!.stops! : undefined; - const pivot = Array.isArray(rg?.pivot) ? rg!.pivot! : [0.5, 0.5]; - const width = typeof rg?.w === 'number' ? rg!.w! : props.w || 0; - const height = typeof rg?.h === 'number' ? rg!.h! : width; + const colors = Array.isArray(rg?.colors) ? rg.colors : []; + const stops = Array.isArray(rg?.stops) ? rg.stops : undefined; + const pivot = Array.isArray(rg?.pivot) ? rg.pivot : [0.5, 0.5]; + const width = typeof rg?.w === 'number' ? rg.w : props.w || 0; + const height = typeof rg?.h === 'number' ? rg.h : width; if (colors.length > 0) { const gradientStops = buildGradientStops(colors, stops); @@ -653,9 +694,9 @@ function updateNodeStyles(node: DOMNode | DOMText) { const lg = shaderProps.linear as | Partial | undefined; - const colors = Array.isArray(lg?.colors) ? lg!.colors! : []; - const stops = Array.isArray(lg?.stops) ? lg!.stops! : undefined; - const angleRad = typeof lg?.angle === 'number' ? lg!.angle! : 0; // radians + const colors = Array.isArray(lg?.colors) ? lg.colors : []; + const stops = Array.isArray(lg?.stops) ? lg.stops : undefined; + const angleRad = typeof lg?.angle === 'number' ? lg.angle : 0; // radians if (colors.length > 0) { const gradientStops = buildGradientStops(colors, stops); @@ -693,6 +734,16 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } + // If there's still no reason to use divBg, check out tint/gradient/subtexture/radius/bgStyle + if (!needsBackgroundLayer && rawImgSrc) { + needsBackgroundLayer = + hasTint || + !!gradient || + srcPos !== null || + radiusStyle !== '' || + bgStyle !== ''; + } + style += radiusStyle; if (needsBackgroundLayer) { @@ -703,14 +754,27 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.div.insertBefore(node.divBg, node.div.firstChild); } + const isSyncSubtextureUpdate = + rawImgSrc != null && + srcPos != null && + !!node.imgEl && + node.imgEl.complete && + node.imgEl.dataset.rawSrc === rawImgSrc; + if (isSyncSubtextureUpdate) { + node.imageLoading = true; + } + let bgLayerStyle = - 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none; overflow: hidden;'; + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none; -webkit-clip-path: inset(0); clip-path: inset(0);'; if (bgStyle) { bgLayerStyle += bgStyle; } if (maskStyle) { bgLayerStyle += maskStyle; } + if (hasDivBgTint && srcPos != null && node.imageLoading) { + bgLayerStyle += 'opacity: 0;'; + } node.divBg.setAttribute('style', bgLayerStyle + radiusStyle); @@ -751,10 +815,19 @@ function updateNodeStyles(node: DOMNode | DOMText) { supportsObjectFit, supportsObjectPosition, ); + + // Reveal only after final fit/positioning is applied + if (node.imgEl) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + } + node.showBackgroundLayer(); node.emit('loaded', payload); }); node.imgEl.addEventListener('error', () => { + node.imageLoading = false; + node.showBackgroundLayer(); if (node.imgEl) { node.imgEl.removeAttribute('src'); node.imgEl.style.display = 'none'; @@ -780,7 +853,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.divBg.appendChild(node.imgEl); } - node.imgEl.setAttribute('style', imgStyle); + node.imgEl.setAttribute('style', imgStyle + radiusStyle); if (hasDivBgTint) { node.imgEl.style.visibility = 'hidden'; @@ -798,6 +871,11 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.imgEl.dataset.rawSrc === rawImgSrc ) { applySubTextureScaling(node, node.imgEl, srcPos); + if (node.imageLoading) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + node.showBackgroundLayer(); + } } if ( !srcPos && @@ -827,6 +905,134 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.imgEl = undefined; } } + } else if (rawImgSrc) { + // Image directly in node.div (without divBg) when there is no tint/gradient + + // Cleanup divBg + if (node.divBg) { + node.divBg.remove(); + node.divBg = undefined; + } + + const isSyncSubtextureUpdate = + srcPos != null && + !!node.imgEl && + node.imgEl.complete && + node.imgEl.dataset.rawSrc === rawImgSrc; + if (isSyncSubtextureUpdate) { + node.imageLoading = true; + } + + if (!node.imgEl) { + node.imgEl = document.createElement('img'); + node.imgEl.alt = ''; + node.imgEl.crossOrigin = 'anonymous'; + node.imgEl.setAttribute('aria-hidden', 'true'); + node.imgEl.setAttribute('loading', 'lazy'); + node.imgEl.removeAttribute('src'); + + node.imgEl.addEventListener('load', () => { + const payload: lng.NodeTextureLoadedPayload = { + type: 'texture', + dimensions: { + w: node.imgEl!.naturalWidth, + h: node.imgEl!.naturalHeight, + }, + }; + node.imgEl!.style.display = ''; + applySubTextureScaling( + node, + node.imgEl!, + node.lazyImageSubTextureProps, + ); + + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = resizeMode?.clipX ?? 0.5; + const clipY = resizeMode?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl!, + resizeMode, + clipX, + clipY, + node.lazyImageSubTextureProps, + supportsObjectFit, + supportsObjectPosition, + ); + + if (node.imgEl) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + } + node.emit('loaded', payload); + }); + + node.imgEl.addEventListener('error', () => { + node.imageLoading = false; + if (node.imgEl) { + node.imgEl.removeAttribute('src'); + node.imgEl.style.display = 'none'; + node.imgEl.removeAttribute('data-rawSrc'); + } + + const failedSrc = + node.imgEl?.dataset.pendingSrc || node.lazyImagePendingSrc || ''; + + const payload: lng.NodeTextureFailedPayload = { + type: 'texture', + error: new Error(`Failed to load image: ${failedSrc}`), + }; + node.emit('failed', payload); + }); + } + + node.lazyImagePendingSrc = rawImgSrc; + node.lazyImageSubTextureProps = srcPos; + node.imgEl.dataset.pendingSrc = rawImgSrc; + + if (node.imgEl.parentElement !== node.div) { + node.div.appendChild(node.imgEl); + } + + node.imgEl.setAttribute('style', imgStyle + radiusStyle); + + if (isRenderStateInBounds(node.renderState)) { + node.applyPendingImageSrc(); + } else if (!node.imgEl.dataset.rawSrc) { + node.imgEl.removeAttribute('src'); + } + + if ( + srcPos && + node.imgEl.complete && + node.imgEl.dataset.rawSrc === rawImgSrc + ) { + applySubTextureScaling(node, node.imgEl, srcPos); + if (node.imageLoading) { + node.imageLoading = false; + node.imgEl.style.opacity = '1'; + } + } + if ( + !srcPos && + node.imgEl.complete && + (!supportsObjectFit || !supportsObjectPosition) && + node.imgEl.dataset.rawSrc === rawImgSrc + ) { + const resizeMode = (node.props.textureOptions as any)?.resizeMode; + const clipX = resizeMode?.clipX ?? 0.5; + const clipY = resizeMode?.clipY ?? 0.5; + computeLegacyObjectFit( + node, + node.imgEl, + resizeMode, + clipX, + clipY, + srcPos, + supportsObjectFit, + supportsObjectPosition, + ); + } } else { node.lazyImagePendingSrc = null; node.lazyImageSubTextureProps = null; @@ -863,20 +1069,13 @@ function updateNodeStyles(node: DOMNode | DOMText) { } } - node.div.setAttribute('style', compactString(style)); - - if (node instanceof DOMNode && node !== node.stage.root) { - const hasTextureSrc = nodeHasTextureSource(node); - if (hasTextureSrc && node.boundsDirty) { - const next = computeRenderStateForNode(node); - if (next != null) { - node.updateRenderState(next); - } - node.boundsDirty = false; - } else if (!hasTextureSrc) { - node.boundsDirty = false; - } + const newStyle = compactString(style); + if (node._lastStyleStr !== newStyle) { + node._lastStyleStr = newStyle; + node.div.setAttribute('style', newStyle); } + + updateRenderStateIfNeeded(node); } const textNodesToMeasure = new Set(); @@ -1019,6 +1218,7 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { setTimeout(updateDOMTextMeasurements, 500); } } else { + // Fallback for devices without FontFaceSet.ready() setTimeout(updateDOMTextMeasurements, 500); } } @@ -1028,8 +1228,8 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { function updateNodeData(node: DOMNode | DOMText) { const data = node.data; - for (let key in data) { - let keyValue: unknown = data[key]; + for (const key in data) { + const keyValue: unknown = data[key]; if (keyValue === undefined) { node.div.removeAttribute('data-' + key); } else { @@ -1134,12 +1334,15 @@ export class DOMNode extends EventEmitter implements IRendererNode { divBg: HTMLElement | undefined; divBorder: HTMLElement | undefined; imgEl: HTMLImageElement | undefined; + imageLoading = false; lazyImagePendingSrc: string | null = null; lazyImageSubTextureProps: | InstanceType['props'] | null = null; boundsDirty = true; children = new Set(); + /** Cached result of the last updateNodeStyles call — avoids redundant setAttribute writes. */ + _lastStyleStr: string = ''; id = ++lastNodeId; @@ -1238,11 +1441,30 @@ export class DOMNode extends EventEmitter implements IRendererNode { } } + showBackgroundLayer() { + if (this.divBg) { + this.divBg.style.opacity = '1'; + } + } + + hideMaskedBackgroundLayer() { + if ( + this.divBg && + (this.divBg.style.maskImage || this.divBg.style.webkitMaskImage) + ) { + this.divBg.style.opacity = '0'; + } + } + applyPendingImageSrc() { if (!this.imgEl) return; const pendingSrc = this.lazyImagePendingSrc; if (!pendingSrc) return; if (this.imgEl.dataset.rawSrc === pendingSrc) return; + // Hide transient frame while source is loading and being fitted. + this.imageLoading = true; + this.imgEl.style.opacity = '0'; + this.hideMaskedBackgroundLayer(); this.imgEl.style.display = ''; this.imgEl.dataset.pendingSrc = pendingSrc; this.imgEl.src = pendingSrc; @@ -1258,7 +1480,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.x = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get y() { return this.props.y; @@ -1268,7 +1490,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.y = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get w() { return this.props.w; @@ -1435,7 +1657,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.scale = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get scaleX() { return this.props.scaleX; @@ -1445,7 +1667,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.scaleX = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get scaleY() { return this.props.scaleY; @@ -1455,7 +1677,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.scaleY = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get mount() { return this.props.mount; @@ -1465,7 +1687,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.mount = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get mountX() { return this.props.mountX; @@ -1475,7 +1697,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.mountX = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get mountY() { return this.props.mountY; @@ -1485,7 +1707,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.mountY = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get pivot() { return this.props.pivot; @@ -1525,7 +1747,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { this.props.rotation = v; this.boundsDirty = true; this.markChildrenBoundsDirty(); - updateNodeStyles(this); + updateTransformOnly(this); } get rtt() { return this.props.rtt; @@ -1791,16 +2013,16 @@ class DOMText extends DOMNode { } function updateRootPosition(this: DOMRendererMain) { - let { canvas, settings } = this; + const { canvas, settings } = this; - let rect = canvas.getBoundingClientRect(); - let top = document.documentElement.scrollTop + rect.top; - let left = document.documentElement.scrollLeft + rect.left; + const rect = canvas.getBoundingClientRect(); + const top = document.documentElement.scrollTop + rect.top; + const left = document.documentElement.scrollLeft + rect.left; - let dpr = settings.deviceLogicalPixelRatio ?? 1; + const dpr = settings.deviceLogicalPixelRatio ?? 1; - let height = Math.ceil(settings.appHeight ?? 1080 / dpr); - let width = Math.ceil(settings.appWidth ?? 1920 / dpr); + const height = Math.ceil(settings.appHeight ?? 1080 / dpr); + const width = Math.ceil(settings.appWidth ?? 1920 / dpr); this.root.div.style.left = `${left}px`; this.root.div.style.top = `${top}px`; @@ -1825,7 +2047,7 @@ export class DOMRendererMain implements IRendererMain { ) { let target: HTMLElement; if (typeof rawTarget === 'string') { - let result = document.getElementById(rawTarget); + const result = document.getElementById(rawTarget); if (result instanceof HTMLElement) { target = result; } else { @@ -1835,7 +2057,7 @@ export class DOMRendererMain implements IRendererMain { target = rawTarget; } - let canvas = document.body.appendChild(document.createElement('canvas')); + const canvas = document.body.appendChild(document.createElement('canvas')); canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0'; From 65fa8c3603e62d2c0cfaa8e87ce1432ab4e40182 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 15 May 2026 16:20:38 +0200 Subject: [PATCH 02/12] fix: update TypeScript configuration for test environment --- eslint.config.js | 1 + tests/tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 53c4bb4..12acb7b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,6 +40,7 @@ export default tseslint.config( parserOptions: { project: true, tsconfigRootDir: import.meta.dirname, + warnOnUnsupportedTypeScriptVersion: false, }, globals: { ...globals.browser, diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 4c5da66..49c08a0 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["vitest/globals"] }, "include": ["**/*", "../src/**/*"] } From aa88cdbcc042c1665ffe9ced846a9ece9806aa91 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Fri, 15 May 2026 17:03:38 +0200 Subject: [PATCH 03/12] fix: await loadFonts in renderer setup for improved font loading --- docs/articles/basics.md | 2 +- docs/articles/migration-2x-to-3.0.md | 2 +- docs/essentials/text.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/articles/basics.md b/docs/articles/basics.md index cb157a4..a273b7e 100644 --- a/docs/articles/basics.md +++ b/docs/articles/basics.md @@ -63,7 +63,7 @@ const TextPage = lazy(() => import('./pages/Text')); // Create the renderer and load fonts const { render } = createRenderer(); -loadFonts(fonts); +await loadFonts(fonts); // Render the app using the HashRouter from SolidRouter render(() => ( diff --git a/docs/articles/migration-2x-to-3.0.md b/docs/articles/migration-2x-to-3.0.md index d1947a8..3ec05b5 100644 --- a/docs/articles/migration-2x-to-3.0.md +++ b/docs/articles/migration-2x-to-3.0.md @@ -122,7 +122,7 @@ Shaders are imported when needed by application. The following example shows how ```typescript const { renderer, render } = createRenderer(); -loadFonts(fonts); +await loadFonts(fonts); // Prepare for RC3 of Renderer import { Rounded, diff --git a/docs/essentials/text.md b/docs/essentials/text.md index e38a31a..9439ba1 100644 --- a/docs/essentials/text.md +++ b/docs/essentials/text.md @@ -74,7 +74,7 @@ Then you'll need to register the custom font in the AppCoreExtensions file: } as const]; // must be called after createRenderer but before render -loadFonts(fonts); +await loadFonts(fonts); ``` From this moment on you'll be able to use the font `ComicSans` anywhere in your App: From c8632a5794ac1793f6c1057b01591519d1f53941 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 11:57:13 +0200 Subject: [PATCH 04/12] fix: eslint any --- docs/essentials/render.md | 8 ----- docs/primitives/createTag.md | 1 - docs/primitives/grid.md | 3 -- docs/primitives/row_column.md | 3 -- eslint.config.js | 2 +- pnpm-lock.yaml | 2 +- src/core/dom-renderer/domRenderer.ts | 41 ++++++++++++++++------- src/core/dom-renderer/domRendererTypes.ts | 15 +++++---- src/core/elementNode.ts | 3 +- src/core/focusManager.ts | 17 +++++++--- src/core/intrinsicTypes.ts | 19 +++++++---- src/primitives/Preserve.tsx | 21 +++++++----- src/solidOpts.ts | 4 +-- 13 files changed, 81 insertions(+), 58 deletions(-) diff --git a/docs/essentials/render.md b/docs/essentials/render.md index 9c89b8d..9234b90 100644 --- a/docs/essentials/render.md +++ b/docs/essentials/render.md @@ -54,11 +54,9 @@ For the latest renderer options read the official [renderer documentation](https ### Config.rendererOptions - **appWidth**: Authored logical pixel width of the application. - - _Default_: `1920` - **appHeight**: Authored logical pixel height of the application. - - _Default_: `1080` - **txMemByteThreshold**: Texture Memory Byte Threshold. When the GPU VRAM used by textures exceeds this threshold, non-visible textures are freed. Set to `0` to disable. @@ -66,30 +64,24 @@ For the latest renderer options read the official [renderer documentation](https - **boundsMargin**: Bounds margin to extend the boundary for adding a CoreNode as Quad. Can be a single number or an array of four numbers. - **deviceLogicalPixelRatio**: Factor to convert app-authored logical coordinates to device logical coordinates. Supports auto-scaling for different resolutions. - - _Default_: `1` - **devicePhysicalPixelRatio**: Factor to convert device logical coordinates to device physical coordinates. Controls the number of physical pixels used per logical pixel. - - _Default_: `window.devicePixelRatio` - **clearColor**: RGBA encoded number for the background color. - - _Default_: `0x00000000` - **Texture Memory Manager Settings**: textureMemory?: Partial; - **fpsUpdateInterval**: Interval in milliseconds for receiving FPS updates. Set to `0` to disable. - - _Default_: `0` - **enableContextSpy**: Includes WebGL context call information in FPS updates. Significantly impacts performance. - - _Default_: `false` - **numImageWorkers**: Number of image workers to use. Improves image loading on multi-core devices. Set to `0` to disable. - - _Default_: `2` - **inspector** diff --git a/docs/primitives/createTag.md b/docs/primitives/createTag.md index 6078465..8d35136 100644 --- a/docs/primitives/createTag.md +++ b/docs/primitives/createTag.md @@ -58,7 +58,6 @@ const App = () => { Creates a tag component from the provided children. - **Parameters**: - - `children`: The SolidTV/SolidTV elements to render into the texture. - **Returns**: diff --git a/docs/primitives/grid.md b/docs/primitives/grid.md index 5eedc44..02da663 100644 --- a/docs/primitives/grid.md +++ b/docs/primitives/grid.md @@ -23,18 +23,15 @@ The `Grid` component is simplified version of combining Column & Row and doesn't ### Behavior 1. **Grid Navigation**: - - The `Grid` supports navigation via `onUp`, `onDown`, `onLeft`, and `onRight` events. - Navigation is column-aware, ensuring horizontal navigation stays within the same row unless looping is enabled. 2. **Looping**: - - When `looping` is enabled, navigation wraps around when reaching the grid boundaries. - For vertical navigation, reaching the last row loops to the first row and vice versa. - For horizontal navigation, reaching the end of a row wraps to the start of the same row. 3. **Focus Handling**: - - The `Grid` manages focus internally using a `focusedIndex` signal. - The `onSelectedChanged` callback is invoked when the focused index changes. diff --git a/docs/primitives/row_column.md b/docs/primitives/row_column.md index 87447fd..320a582 100644 --- a/docs/primitives/row_column.md +++ b/docs/primitives/row_column.md @@ -34,18 +34,15 @@ The components accept the following props: #### Behavior 1. **Navigation**: - - The `Row` listens for left and right navigation events. - The `Column` listens for up and down navigation events. - The `onLeft` and `onRight` handlers can be customized, and default navigation logic is provided via `handleNavigation`. 2. **Focus Management**: - - Focus events are handled via the `onFocus` prop. - When focus changes, the row ensures that the currently selected child is highlighted and optionally scrolls into view. 3. **Scrolling**: - - The `withScrolling` utility is used to handle scrolling behavior when the `scroll` prop is set to `'auto'` or `'always'`. 4. **Styling**: diff --git a/eslint.config.js b/eslint.config.js index 12acb7b..dcff811 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,7 @@ const relaxedTypedRules = { '@typescript-eslint/no-unused-expressions': 'warn', '@typescript-eslint/unbound-method': 'warn', '@typescript-eslint/no-this-alias': 'warn', - '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/restrict-template-expressions': 'warn', '@typescript-eslint/prefer-promise-reject-errors': 'warn', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3021ea0..cfda467 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,7 +82,7 @@ importers: specifier: ^8.59.2 version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) vite: - specifier: 8.0.13 + specifier: ^8.0.13 version: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) vite-plugin-solid: specifier: ^2.11.12 diff --git a/src/core/dom-renderer/domRenderer.ts b/src/core/dom-renderer/domRenderer.ts index 419d62e..7aebc97 100644 --- a/src/core/dom-renderer/domRenderer.ts +++ b/src/core/dom-renderer/domRenderer.ts @@ -946,9 +946,15 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.lazyImageSubTextureProps, ); - const resizeMode = (node.props.textureOptions as any)?.resizeMode; - const clipX = resizeMode?.clipX ?? 0.5; - const clipY = resizeMode?.clipY ?? 0.5; + const resizeMode = node.props.textureOptions?.resizeMode; + const clipX = + resizeMode?.type !== 'contain' && resizeMode?.clipX + ? resizeMode?.clipX + : 0.5; + const clipY = + resizeMode?.type !== 'contain' && resizeMode?.clipY + ? resizeMode?.clipY + : 0.5; computeLegacyObjectFit( node, node.imgEl!, @@ -1019,9 +1025,15 @@ function updateNodeStyles(node: DOMNode | DOMText) { (!supportsObjectFit || !supportsObjectPosition) && node.imgEl.dataset.rawSrc === rawImgSrc ) { - const resizeMode = (node.props.textureOptions as any)?.resizeMode; - const clipX = resizeMode?.clipX ?? 0.5; - const clipY = resizeMode?.clipY ?? 0.5; + const resizeMode = node.props.textureOptions?.resizeMode; + const clipX = + resizeMode?.type !== 'contain' && resizeMode?.clipX + ? resizeMode?.clipX + : 0.5; + const clipY = + resizeMode?.type !== 'contain' && resizeMode?.clipY + ? resizeMode?.clipY + : 0.5; computeLegacyObjectFit( node, node.imgEl, @@ -1212,8 +1224,11 @@ function scheduleUpdateDOMTextMeasurement(node: DOMText) { const fonts = document.fonts; if (fonts.status === 'loaded') { setTimeout(updateDOMTextMeasurements); - } else if (fonts.ready && typeof fonts.ready.then === 'function') { - fonts.ready.then(updateDOMTextMeasurements); + } else if ( + fonts.ready != null && + typeof fonts.ready.then === 'function' + ) { + void fonts.ready.then(updateDOMTextMeasurements); } else { setTimeout(updateDOMTextMeasurements, 500); } @@ -2038,8 +2053,10 @@ export class DOMRendererMain implements IRendererMain { root: DOMNode; canvas: HTMLCanvasElement; stage: IRendererStage; - private eventListeners: Map void>> = - new Map(); + private eventListeners: Map< + string, + Set<(target: unknown, data: unknown) => void> + > = new Map(); constructor( public settings: DomRendererMainSettings, @@ -2173,12 +2190,12 @@ export class DOMRendererMain implements IRendererMain { ): void; emit( event: Extract, - target: any, + target: unknown, data: Parameters[1], ): void; emit( event: Extract, - targetOrData: any, + targetOrData: unknown, maybeData?: Parameters[1], ): void { const listeners = this.eventListeners.get(event); diff --git a/src/core/dom-renderer/domRendererTypes.ts b/src/core/dom-renderer/domRendererTypes.ts index 6d62a6d..73cf833 100644 --- a/src/core/dom-renderer/domRendererTypes.ts +++ b/src/core/dom-renderer/domRendererTypes.ts @@ -83,8 +83,10 @@ export interface IRendererNodeShaded extends EventEmitter { } /** Based on {@link lng.INodeProps} */ -export interface IRendererNodeProps - extends Omit { +export interface IRendererNodeProps extends Omit< + lng.INodeProps, + 'shader' | 'parent' +> { shader: IRendererShader | null; parent: IRendererNode | null; } @@ -97,8 +99,10 @@ export interface IRendererNode extends IRendererNodeShaded, IRendererNodeProps { } /** Based on {@link lng.ITextNodeProps} */ -export interface IRendererTextNodeProps - extends Omit { +export interface IRendererTextNodeProps extends Omit< + lng.ITextNodeProps, + 'shader' | 'parent' +> { shader: IRendererShader | null; parent: IRendererNode | null; fontWeight?: string; @@ -107,8 +111,7 @@ export interface IRendererTextNodeProps /** Based on {@link lng.ITextNode} */ export interface IRendererTextNode - extends IRendererNodeShaded, - IRendererTextNodeProps { + extends IRendererNodeShaded, IRendererTextNodeProps { div?: HTMLElement; props: IRendererTextNodeProps; renderState: lng.CoreNodeRenderState; diff --git a/src/core/elementNode.ts b/src/core/elementNode.ts index 4d4523c..aa18561 100644 --- a/src/core/elementNode.ts +++ b/src/core/elementNode.ts @@ -995,8 +995,9 @@ export class ElementNode { props: Partial>, animationSettings?: AnimationSettings, ): IAnimationController { - isDev && + if (isDev) { assertTruthy(this.rendered, 'Node must be rendered before animating'); + } return (this.lng as IRendererNode).animate( props, animationSettings || this.animationSettings || {}, diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index f1407e4..f934c0f 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -8,7 +8,9 @@ import type { } from './focusKeyTypes.js'; import { isFunction } from './utils.js'; -const keyMapEntries: Record = { +type KeyMapEntries = Record; + +const keyMapEntries: KeyMapEntries = { ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', @@ -24,18 +26,23 @@ const keyHoldMapEntries: Record = { // Enter: 'EnterHold', }; -const flattenKeyMap = (keyMap: any, targetMap: any): void => { +const flattenKeyMap = ( + keyMap: Partial, + targetMap: KeyMapEntries, +): KeyMapEntries => { + const newTargetMap = targetMap; for (const [key, value] of Object.entries(keyMap)) { if (Array.isArray(value)) { value.forEach((v) => { - targetMap[v] = key; + newTargetMap[v] = key; }); } else if (value === null) { - delete targetMap[key]; + delete newTargetMap[key]; } else { - targetMap[value as keyof any] = key; + newTargetMap[value as KeyNameOrKeyCode] = key; } } + return newTargetMap; }; let needFocusDebugStyles = true; diff --git a/src/core/intrinsicTypes.ts b/src/core/intrinsicTypes.ts index 7ee5a85..6f6cd6f 100644 --- a/src/core/intrinsicTypes.ts +++ b/src/core/intrinsicTypes.ts @@ -24,16 +24,18 @@ export type AddColorString = { [K in keyof T]: K extends `color${string}` ? string | number : T[K]; }; -export interface BorderStyleObject - extends AddColorString> { +export interface BorderStyleObject extends AddColorString< + Partial +> { width?: number | [number, number, number, number]; gap?: number; align?: 'inside' | 'outside' | 'center'; fill?: number | string; } -export interface SingleBorderStyleObject - extends AddColorString> { +export interface SingleBorderStyleObject extends AddColorString< + Partial +> { width?: number; w?: number; gap?: number; @@ -104,7 +106,8 @@ type CleanElementNode = NewOmit< >; /** Node text, children of a ElementNode of type TextNode */ export interface ElementText - extends NewOmit< + extends + NewOmit< ElementNode, '_type' | 'parent' | 'children' | 'src' | 'scale' | 'fontFamily' >, @@ -117,7 +120,8 @@ export interface ElementText } export interface NodeProps - extends RendererNode, + extends + RendererNode, EventHandlers, EventHandlers, FocusNode, @@ -147,7 +151,8 @@ export interface NodeStyles extends NewOmit { } export interface TextProps - extends RendererText, + extends + RendererText, Partial< NewOmit< CleanElementNode, diff --git a/src/primitives/Preserve.tsx b/src/primitives/Preserve.tsx index 76b3bcd..1469d3d 100644 --- a/src/primitives/Preserve.tsx +++ b/src/primitives/Preserve.tsx @@ -1,18 +1,23 @@ -import * as s from 'solid-js' -import * as lng from '@solidtv/solid' +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; function Preserve(props: lng.NodeProps): s.JSX.Element { - - const view = as any as lng.ElementNode + const view = () as unknown as lng.ElementNode; view.preserve = true; - view.onRender ??= () => {view.hidden = false} - view.onRemove ??= () => {view.hidden = true} + view.onRender ??= () => { + view.hidden = false; + }; + view.onRemove ??= () => { + view.hidden = true; + }; - s.onCleanup(() => {view.destroy()}) + s.onCleanup(() => { + view.destroy(); + }); - return view as any as s.JSX.Element + return view as unknown as s.JSX.Element; } export default Preserve; diff --git a/src/solidOpts.ts b/src/solidOpts.ts index a605e26..dee1402 100644 --- a/src/solidOpts.ts +++ b/src/solidOpts.ts @@ -11,10 +11,10 @@ import { import type { SolidNode, SolidRendererOptions } from './types.js'; Object.defineProperty(ElementNode.prototype, 'preserve', { - get(): boolean | undefined { + get(this: ElementNode): boolean | undefined { return this._queueDelete === 0; }, - set(v: boolean) { + set(this: ElementNode, v: boolean) { this._queueDelete = v ? 0 : undefined; }, }); From d88c5e1ae034e07dc941567c124c36ee2ec8bb68 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 15:16:58 +0200 Subject: [PATCH 05/12] feat: export HashRouter and HashRouterProps from router.js --- src/primitives/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/primitives/index.ts b/src/primitives/index.ts index d93e94d..17dcad0 100644 --- a/src/primitives/index.ts +++ b/src/primitives/index.ts @@ -22,6 +22,7 @@ export * from './VirtualGrid.jsx'; export * from './Virtual.jsx'; export * from './utils/withScrolling.js'; export * from './createTag.jsx'; +export { HashRouter, type HashRouterProps } from './router.js'; export { type AnyFunction, chainFunctions, From fff2b3bae7dd6e2d866609fbb97f45f67d4964e5 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 15:45:23 +0200 Subject: [PATCH 06/12] fix(config): remove globalThis prefix for SOLIDTV constants --- src/core/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/config.ts b/src/core/config.ts index 39fa9b4..1aedbea 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -24,10 +24,10 @@ import { export const isDev = !!(import.meta.env && import.meta.env.DEV); /** Whether the DOM renderer is used instead of `@solidtv/renderer` */ -export const DOM_RENDERING = globalThis.SOLIDTV_DOM_RENDERING === true; +export const DOM_RENDERING = SOLIDTV_DOM_RENDERING === true; /** Whether element shaders are enabled */ -export const SHADERS_ENABLED = globalThis.SOLIDTV_DISABLE_SHADERS !== true; +export const SHADERS_ENABLED = SOLIDTV_DISABLE_SHADERS !== true; /** RUNTIME LIGHTNING CONFIGURATION \ From 986a0659870c8d727e3e8b9ff1b8be110d175214 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 16:50:28 +0200 Subject: [PATCH 07/12] feat: implement applyLegacyObjectFit function for optimization --- src/core/dom-renderer/domRenderer.ts | 107 +++++++++++---------------- 1 file changed, 42 insertions(+), 65 deletions(-) diff --git a/src/core/dom-renderer/domRenderer.ts b/src/core/dom-renderer/domRenderer.ts index 7aebc97..575030e 100644 --- a/src/core/dom-renderer/domRenderer.ts +++ b/src/core/dom-renderer/domRenderer.ts @@ -298,6 +298,32 @@ function updateRenderStateIfNeeded(node: DOMNode | DOMText): void { } } +function applyLegacyObjectFit( + node: DOMNode, + img: HTMLImageElement, + srcPos: InstanceType['props'] | null, +): void { + const resizeMode = node.props.textureOptions?.resizeMode; + const clipX = + resizeMode?.type !== 'contain' && resizeMode?.clipX + ? resizeMode?.clipX + : 0.5; + const clipY = + resizeMode?.type !== 'contain' && resizeMode?.clipY + ? resizeMode?.clipY + : 0.5; + computeLegacyObjectFit( + node, + img, + resizeMode, + clipX, + clipY, + srcPos, + supportsObjectFit, + supportsObjectPosition, + ); +} + function updateNodeStyles(node: DOMNode | DOMText) { const { props } = node; @@ -765,7 +791,14 @@ function updateNodeStyles(node: DOMNode | DOMText) { } let bgLayerStyle = - 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none; -webkit-clip-path: inset(0); clip-path: inset(0);'; + 'position: absolute; top:0; left:0; right:0; bottom:0; z-index: -1; pointer-events: none;'; + // overflow:hidden clips the transformed imgEl to prevent sprite bleed-through + // for non-tinted subtextures. NOT applied when hasDivBgTint because + // overflow:hidden + CSS mask-image causes a 1px white border artifact on WebKit. + // Tinted nodes use mask-image for visual clipping, so overflow:hidden is unnecessary. + if (srcPos !== null && !hasDivBgTint) { + bgLayerStyle += 'overflow: hidden;'; + } if (bgStyle) { bgLayerStyle += bgStyle; } @@ -802,19 +835,9 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.lazyImageSubTextureProps, ); - const resizeMode = (node.props.textureOptions as any)?.resizeMode; - const clipX = resizeMode?.clipX ?? 0.5; - const clipY = resizeMode?.clipY ?? 0.5; - computeLegacyObjectFit( - node, - node.imgEl!, - resizeMode, - clipX, - clipY, - node.lazyImageSubTextureProps, - supportsObjectFit, - supportsObjectPosition, - ); + if (!node.lazyImageSubTextureProps) { + applyLegacyObjectFit(node, node.imgEl!, null); + } // Reveal only after final fit/positioning is applied if (node.imgEl) { @@ -883,19 +906,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { (!supportsObjectFit || !supportsObjectPosition) && node.imgEl.dataset.rawSrc === rawImgSrc ) { - const resizeMode = (node.props.textureOptions as any)?.resizeMode; - const clipX = resizeMode?.clipX ?? 0.5; - const clipY = resizeMode?.clipY ?? 0.5; - computeLegacyObjectFit( - node, - node.imgEl, - resizeMode, - clipX, - clipY, - srcPos, - supportsObjectFit, - supportsObjectPosition, - ); + applyLegacyObjectFit(node, node.imgEl, srcPos); } } else { node.lazyImagePendingSrc = null; @@ -946,25 +957,9 @@ function updateNodeStyles(node: DOMNode | DOMText) { node.lazyImageSubTextureProps, ); - const resizeMode = node.props.textureOptions?.resizeMode; - const clipX = - resizeMode?.type !== 'contain' && resizeMode?.clipX - ? resizeMode?.clipX - : 0.5; - const clipY = - resizeMode?.type !== 'contain' && resizeMode?.clipY - ? resizeMode?.clipY - : 0.5; - computeLegacyObjectFit( - node, - node.imgEl!, - resizeMode, - clipX, - clipY, - node.lazyImageSubTextureProps, - supportsObjectFit, - supportsObjectPosition, - ); + if (!node.lazyImageSubTextureProps) { + applyLegacyObjectFit(node, node.imgEl!, null); + } if (node.imgEl) { node.imageLoading = false; @@ -1025,25 +1020,7 @@ function updateNodeStyles(node: DOMNode | DOMText) { (!supportsObjectFit || !supportsObjectPosition) && node.imgEl.dataset.rawSrc === rawImgSrc ) { - const resizeMode = node.props.textureOptions?.resizeMode; - const clipX = - resizeMode?.type !== 'contain' && resizeMode?.clipX - ? resizeMode?.clipX - : 0.5; - const clipY = - resizeMode?.type !== 'contain' && resizeMode?.clipY - ? resizeMode?.clipY - : 0.5; - computeLegacyObjectFit( - node, - node.imgEl, - resizeMode, - clipX, - clipY, - srcPos, - supportsObjectFit, - supportsObjectPosition, - ); + applyLegacyObjectFit(node, node.imgEl, srcPos); } } else { node.lazyImagePendingSrc = null; From 377680f9d5b03fd4721a048eef7b4eb549b213d7 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 16:58:49 +0200 Subject: [PATCH 08/12] fix(config): fix tests --- src/core/config.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/config.ts b/src/core/config.ts index 1aedbea..506912f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -24,10 +24,14 @@ import { export const isDev = !!(import.meta.env && import.meta.env.DEV); /** Whether the DOM renderer is used instead of `@solidtv/renderer` */ -export const DOM_RENDERING = SOLIDTV_DOM_RENDERING === true; +export const DOM_RENDERING = + typeof SOLIDTV_DOM_RENDERING !== 'undefined' && + SOLIDTV_DOM_RENDERING === true; /** Whether element shaders are enabled */ -export const SHADERS_ENABLED = SOLIDTV_DISABLE_SHADERS !== true; +export const SHADERS_ENABLED = + typeof SOLIDTV_DISABLE_SHADERS === 'undefined' || + SOLIDTV_DISABLE_SHADERS !== true; /** RUNTIME LIGHTNING CONFIGURATION \ From c68f3dfd1d202666b0236200a53aca3fcac7e6b6 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 19:28:50 +0200 Subject: [PATCH 09/12] feat: remove HashRouter and HashRouterProps exports from index --- src/primitives/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/primitives/index.ts b/src/primitives/index.ts index 17dcad0..d93e94d 100644 --- a/src/primitives/index.ts +++ b/src/primitives/index.ts @@ -22,7 +22,6 @@ export * from './VirtualGrid.jsx'; export * from './Virtual.jsx'; export * from './utils/withScrolling.js'; export * from './createTag.jsx'; -export { HashRouter, type HashRouterProps } from './router.js'; export { type AnyFunction, chainFunctions, From 473b2d98f280322f0d2eed9f062acfc481a316f6 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Mon, 18 May 2026 19:34:48 +0200 Subject: [PATCH 10/12] doc: update import path for HashRouter in router documentation --- docs/primitives/router.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/primitives/router.md b/docs/primitives/router.md index c7e220c..2cdef44 100644 --- a/docs/primitives/router.md +++ b/docs/primitives/router.md @@ -5,7 +5,7 @@ The `HashRouter` primitive is based on the [SolidTV Router](https://github.com/s ### Usage ```jsx -import { HashRouter } from '@solidtv/solid/primitives'; +import { HashRouter } from '@solidtv/solid/primitives/router'; From a41f6db2fe4f6076c3403b9da9bda1f02187a9be Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 19 May 2026 13:19:24 +0200 Subject: [PATCH 11/12] fix: eslint fix --- src/core/elementNode.ts | 38 +++++++++++++++++++------------------- src/primitives/Image.tsx | 4 ++-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/core/elementNode.ts b/src/core/elementNode.ts index 93300ba..4c783b7 100644 --- a/src/core/elementNode.ts +++ b/src/core/elementNode.ts @@ -145,7 +145,7 @@ function buildFontTemplate() { _fontFamilyIdx = tpl.length; _fontFamilyWithWeight = `${fs.fontFamily}${fs.fontWeight || ''}`; } - tpl.push([key, (fs as any)[key]]); + tpl.push([key, fs[key]]); } } _fontTemplate = tpl; @@ -153,8 +153,8 @@ function buildFontTemplate() { const parseAndAssignShaderProps = ( prefix: string, - obj: Record, - props: Record = {}, + obj: Record, + props: Record = {}, ) => { if (!obj) return; @@ -688,7 +688,7 @@ export interface ElementNode extends RendererNode, FocusNode { * * @see https://lightning-tv.github.io/solid/#/flow/ondestroy */ - onDestroy?: (this: ElementNode, el: ElementNode) => Promise | void; + onDestroy?: (this: ElementNode, el: ElementNode) => Promise | void; /** * Optional handlers for when the element is rendered—after creation and when switching parents. * @@ -876,7 +876,7 @@ export class ElementNode { (Config.fontWeightAlias && (Config.fontWeightAlias[v as string] as number | string)) ?? v; - (this.lng as any).fontFamily = + (this.lng as ElementNode).fontFamily = `${this.fontFamily || Config.fontSettings?.fontFamily}${weight}`; } @@ -886,7 +886,7 @@ export class ElementNode { set fontFamily(v) { this._fontFamily = v; - (this.lng as any).fontFamily = v; + (this.lng as ElementNode).fontFamily = v; } get fontFamily() { @@ -983,7 +983,7 @@ export class ElementNode { return animationController.start(); } - return (this.lng as any).animateProp( + return (this.lng as INode).animateProp( name, value, animationSettings || this.animationSettings || {}, @@ -1110,7 +1110,7 @@ export class ElementNode { // If onDestroy returns a promise, wait for it to resolve before destroying // Useful with animations waitUntilStopped method which returns promise if (destroyPromise instanceof Promise) { - destroyPromise.then(() => this._destroy()); + void destroyPromise.then(() => this._destroy()); } else { this._destroy(); } @@ -1249,7 +1249,7 @@ export class ElementNode { * @param val - A value to determine if the element should autofocus. * A truthy value enables autofocus, otherwise disables it. */ - set autofocus(val: any) { + set autofocus(val: boolean | undefined) { this._autofocus = val; // Defer setFocus so children render first (forwardFocus needs them). // The post-mutation focus phase calls setFocus on this element. @@ -1300,7 +1300,7 @@ export class ElementNode { return this._requiresLayout; } - set updateLayoutOn(v: any) { + set updateLayoutOn(_v: unknown) { this.updateLayout(); } @@ -1310,7 +1310,7 @@ export class ElementNode { updateLayout() { if (this.hasChildren) { - isDev && log('Layout: ', this); + if (isDev) log('Layout: ', this); if (this.display === 'flex' && this.flexGrow && this.width === 0) { return; @@ -1340,10 +1340,10 @@ export class ElementNode { } _stateChanged() { - isDev && log('State Changed: ', this, this.states); + if (isDev) log('State Changed: ', this, this.states); if (isDev) { - const div = (this.lng as any)?.div as HTMLElement | undefined; + const div = (this.lng as IRendererNode)?.div; if (div) { if (this.states.length > 0) { div.dataset.states = this.states.join(' '); @@ -1557,7 +1557,7 @@ export class ElementNode { props.shader = Config.convertToShader(node, props.shader); } - isDev && log('Rendering: ', this, props); + if (isDev) log('Rendering: ', this, props); node.lng = renderer.createTextNode( props as Partial & Partial, @@ -1597,7 +1597,7 @@ export class ElementNode { props.shader = Config.convertToShader(node, props.shader); } - isDev && log('Rendering: ', this, props); + if (isDev) log('Rendering: ', this, props); node.lng = renderer.createNode( props as Partial> & Partial, @@ -1608,7 +1608,7 @@ export class ElementNode { for (const child of node.children) { if (isElementNode(child) && isINode(child.lng)) { - child.lng.parent = node.lng as any; + child.lng.parent = node.lng as INode; } } } @@ -1636,7 +1636,7 @@ export class ElementNode { } // L3 Inspector adds div to the lng object - const div: HTMLElement | undefined = (node.lng as any)?.div; + const div: HTMLElement | undefined = (node.lng as IRendererNode)?.div; if (isDev && div) { div.element = node; if (node._states && node._states.length > 0) { @@ -1649,7 +1649,7 @@ export class ElementNode { const numChildren = node.children.length; for (let i = 0; i < numChildren; i++) { const c = node.children[i]; - isDev && assertTruthy(c, 'Child is undefined'); + if (isDev) assertTruthy(c, 'Child is undefined'); // Text elements sneak in from Solid creating tracked nodes if (isElementNode(c)) { c.render(); @@ -1662,7 +1662,7 @@ export class ElementNode { schedulePostMutation(); } - node._autofocus && node.setFocus(); + if (node._autofocus) node.setFocus(); } } diff --git a/src/primitives/Image.tsx b/src/primitives/Image.tsx index 884ea94..7a6fd99 100644 --- a/src/primitives/Image.tsx +++ b/src/primitives/Image.tsx @@ -1,5 +1,5 @@ import { type Component, createRenderEffect, createSignal } from 'solid-js'; -import { renderer, type NodeProps } from '@solidtv/solid'; +import { ImageTexture, renderer, type NodeProps } from '@solidtv/solid'; import { Config } from '../core/config.js'; export interface ImageProps extends NodeProps { @@ -10,7 +10,7 @@ export interface ImageProps extends NodeProps { } export const Image: Component = (props) => { - const [texture, setTexture] = createSignal(null); + const [texture, setTexture] = createSignal(null); const [src, setSrc] = createSignal(props.placeholder || null); createRenderEffect(() => { From 199c59c16a1d35c9aa271f6c05b0ddb7f544fc47 Mon Sep 17 00:00:00 2001 From: Mirko Pecora Date: Tue, 19 May 2026 13:55:24 +0200 Subject: [PATCH 12/12] fix: eslint --- src/core/clickInspector.ts | 3 ++- src/core/focusManager.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/core/clickInspector.ts b/src/core/clickInspector.ts index 9c8cc73..835cb34 100644 --- a/src/core/clickInspector.ts +++ b/src/core/clickInspector.ts @@ -1,6 +1,7 @@ import { Config, isDev } from './config.js'; import { isElementNode } from './utils.js'; import type { ElementNode } from './elementNode.js'; +import { IRendererNode } from './dom-renderer/domRendererTypes.js'; let installed = false; @@ -48,7 +49,7 @@ function handleClick(event: MouseEvent) { const el = findDeepestAtPosition(root, event.clientX, event.clientY); event.preventDefault(); event.stopPropagation(); - const lng = el.lng as any; + const lng = el.lng as IRendererNode; const label = el.componentName || el._type; const loc = el.componentLocation ? ` @ ${el.componentLocation}` : ''; console.log( diff --git a/src/core/focusManager.ts b/src/core/focusManager.ts index f934c0f..98dddca 100644 --- a/src/core/focusManager.ts +++ b/src/core/focusManager.ts @@ -1,4 +1,5 @@ import { Config, isDev } from './config.js'; +import { IRendererNode } from './dom-renderer/domRendererTypes.js'; export type * from './focusKeyTypes.js'; import { ElementNode } from './elementNode.js'; import type { @@ -124,7 +125,7 @@ let _pendingHistoryKey: { const getElementLabel = (elm: ElementNode | undefined): string => { if (!elm) return 'None'; // ElementNode exposes _id internally; componentName comes from the Babel devtools plugin - const id = (elm as any).id ?? elm._id; + const id = elm.id ?? elm._id; return id ?? elm.componentName ?? 'Unknown'; }; @@ -180,14 +181,14 @@ export const printFocusHistory = (count: number): void => { key: e.mappedKey ?? e.keyPressed ?? '—', next: getElementLabel(e.next), nextElm: e.next, - nextDiv: (e.next.lng as any).div as HTMLDivElement, + nextDiv: (e.next.lng as IRendererNode).div, })), ); // 2. Expose the most recent element for easy inspection const lastEntry = entries[entries.length - 1]; if (lastEntry) { - const lastElm = (lastEntry.next.lng as any)?.div; + const lastElm = (lastEntry.next.lng as IRendererNode)?.div; if (lastElm) { (window as any).$f = lastElm; }