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
34 changes: 34 additions & 0 deletions projects/core/src/internal/decorators/scoped-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// SPDX-License-Identifier: Apache-2.0

/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { LitElement } from 'lit';
import { html as staticHtml, unsafeStatic } from 'lit/static-html.js';
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { createFixture, removeFixture } from '@internals/testing';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use elementIsStable in this unit test before asserting render internals.

This test uses createFixture but skips the elementIsStable pattern required for .test.ts, which can make stability timing brittle across environments.

🧪 Suggested update
-import { createFixture, removeFixture } from '`@internals/testing`';
+import { createFixture, elementIsStable, removeFixture } from '`@internals/testing`';
...
     try {
       await element.updateComplete;
+      await elementIsStable(element);
 
       expect(typeof element.renderOptions.creationScope?.importNode).toBe('function');
       expect(element.shadowRoot!.querySelector(child.metadata.tag)).toBeInstanceOf(child);

As per coding guidelines "**/*.test.ts: Unit tests (*.test.ts) must follow patterns defined in /projects/site/src/docs/internal/guidelines/testing-unit.md, including createFixture and elementIsStable patterns".

Also applies to: 115-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@projects/core/src/internal/decorators/scoped-registry.test.ts` at line 8, The
test uses createFixture without waiting for stability; update the test to import
and await elementIsStable from the testing helpers (e.g., import {
createFixture, removeFixture, elementIsStable } from '`@internals/testing`') and
call await elementIsStable(fixture) immediately after createFixture(...) and
before any assertions that inspect render internals (apply same change for the
other occurrences in this file such as the blocks around the checks at the later
assertions).

import { GlobalStateService } from '../services/global.service.js';
import type { ElementDefinition } from '../types/index.js';
import { supportsScopedRegistry } from '../utils/dom.js';
Expand Down Expand Up @@ -87,4 +90,35 @@ describe('scopedRegistry', () => {

expect((element.shadowRootOptions as ShadowRootInit).mode).toBe('closed');
});

it.skipIf(!supportsScopedRegistry)('should use the scoped shadow root as the Lit creation scope', async () => {
const child = createMockElement();
const childTag = unsafeStatic(child.metadata.tag);
const hostTag = `nve-test-scoped-host-${uid}-${counter++}`;
const host = unsafeStatic(hostTag);

class HostElement extends LitElement {
static metadata = { version: '0.0.0', tag: hostTag };
static elementDefinitions = { [child.metadata.tag]: child };

render() {
return staticHtml`<${childTag}></${childTag}>`;
}
}

scopedRegistry()(HostElement as unknown as Function);
customElements.define(hostTag, HostElement);

const fixture = await createFixture(staticHtml`<${host}></${host}>`);
const element = fixture.querySelector<LitElement>(hostTag)!;

try {
await element.updateComplete;

expect(typeof element.renderOptions.creationScope?.importNode).toBe('function');
expect(element.shadowRoot!.querySelector(child.metadata.tag)).toBeInstanceOf(child);
} finally {
removeFixture(fixture);
}
});
});
41 changes: 41 additions & 0 deletions projects/core/src/internal/decorators/scoped-registry.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,50 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { RenderOptions } from 'lit';
import { GlobalStateService } from '../services/global.service.js';
import type { ElementDefinition, LegacyDecoratorTarget } from '../types/index.js';
import { defineElement, supportsScopedRegistry } from '../utils/dom.js';

interface ScopedRegistryHost extends HTMLElement {
createRenderRoot?: () => HTMLElement | DocumentFragment;
renderOptions?: RenderOptions;
}

const litCreationScopeElements = new WeakSet<ElementDefinition>();

/** Lit passes a legacy `deep` boolean, but scoped registries require `ImportNodeOptions`. https://html.spec.whatwg.org/multipage/custom-elements.html#scoped-custom-element-registries */
function createScopedCreationScope(ownerDocument: Document, customElementRegistry: CustomElementRegistry) {
return {
importNode: (node: Node, deep = false) =>
ownerDocument.importNode(node, {
customElementRegistry,
selfOnly: !deep
})
} satisfies NonNullable<RenderOptions['creationScope']>;
}

function attachLitCreationScope(element: ElementDefinition, customElementRegistry: CustomElementRegistry) {
if (litCreationScopeElements.has(element)) return;

const host = element.prototype as ScopedRegistryHost;
const createRenderRoot = host.createRenderRoot;
if (!createRenderRoot) return;

litCreationScopeElements.add(element);
Object.defineProperty(host, 'createRenderRoot', {
configurable: true,
value(this: ScopedRegistryHost) {
const renderRoot = createRenderRoot.call(this);
if (renderRoot instanceof ShadowRoot) {
this.renderOptions ??= {};
this.renderOptions.creationScope = createScopedCreationScope(renderRoot.ownerDocument, customElementRegistry);
}
return renderRoot;
}
});
Comment on lines +35 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether any code reassigns createRenderRoot (would be impacted by writable:false).
rg -nP --type=ts -C3 '\bcreateRenderRoot\s*='
rg -nP --type=ts -C3 'Object\.defineProperty\([^)]*createRenderRoot'

Repository: NVIDIA/elements

Length of output: 1377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Check for any direct reassignments to createRenderRoot (should catch strict-mode assignment hazards)
rg -n --type=ts -S '\.createRenderRoot\s*=' .
rg -n --type=tsx -S '\.createRenderRoot\s*=' .
rg -n --type=ts -S '\bcreateRenderRoot\s*=' projects/core projects/site projects/starters 2>/dev/null || true

# 2) Check for defineProperty redefinitions targeting the 'createRenderRoot' key
rg -n --type=ts -S "Object\.defineProperty\([^)]*'createRenderRoot'" .
rg -n --type=ts -S "Object\.defineProperty\([^)]*\"createRenderRoot\"" .
rg -n --type=ts -S "'createRenderRoot'\s*," projects/core projects/site projects/starters 2>/dev/null || true

# 3) Sanity: list all other occurrences to see if any mixins/decorators reference this symbol
rg -n --type=ts -S '\bcreateRenderRoot\b' projects/core/src/internal/decorators

Repository: NVIDIA/elements

Length of output: 41


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Count matches (exit code 1 if 0 matches; so force through with || true)
echo "== direct assignments to createRenderRoot =="
rg -n --type=ts -S '\.createRenderRoot\s*=' projects || true
rg -n --type=tsx -S '\.createRenderRoot\s*=' projects || true
echo

echo "== any identifier createRenderRoot in core decorators =="
rg -n --type=ts -S '\bcreateRenderRoot\b' projects/core/src/internal/decorators || true
echo

echo "== defineProperty targeting createRenderRoot key =="
rg -n --type=ts -S "Object\.defineProperty\([^)]*'createRenderRoot'" projects || true
rg -n --type=ts -S "Object\.defineProperty\([^)]*\"createRenderRoot\"" projects || true
echo

echo "== other decorators mixins usage =="
rg -n --type=ts -S '\bcreateRenderRoot\b' projects/core/src/internal || true

Repository: NVIDIA/elements

Length of output: 1599


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== scoped-registry.ts (around createRenderRoot patch) =="
sed -n '1,120p' projects/core/src/internal/decorators/scoped-registry.ts | nl -ba | sed -n '1,120p'

echo
echo "== i18n.controller.examples.ts (around createRenderRoot) =="
sed -n '1,120p' projects/core/src/internal/controllers/i18n.controller.examples.ts | nl -ba | sed -n '1,120p'

Repository: NVIDIA/elements

Length of output: 177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== projects/core/src/internal/decorators/scoped-registry.ts (lines 1-120) =="
cat -n projects/core/src/internal/decorators/scoped-registry.ts | sed -n '1,140p'

echo
echo "== projects/core/src/internal/controllers/i18n.controller.examples.ts (lines 1-120) =="
cat -n projects/core/src/internal/controllers/i18n.controller.examples.ts | sed -n '1,140p'

Repository: NVIDIA/elements

Length of output: 5995


Hardening: consider setting writable: true on the createRenderRoot patch

This repo has no additional createRenderRoot = ... reassignment or other Object.defineProperty(..., 'createRenderRoot' ...) uses beyond the scoped-registry patch, so writable: false is unlikely to break existing composition.

🔧 Optional hardening
 Object.defineProperty(host, 'createRenderRoot', {
   configurable: true,
+  writable: true,
   value(this: ScopedRegistryHost) {
     const renderRoot = createRenderRoot.call(this);
     if (renderRoot instanceof ShadowRoot) {
       this.renderOptions ??= {};
       this.renderOptions.creationScope = createScopedCreationScope(renderRoot.ownerDocument, customElementRegistry);
     }
     return renderRoot;
   }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Object.defineProperty(host, 'createRenderRoot', {
configurable: true,
value(this: ScopedRegistryHost) {
const renderRoot = createRenderRoot.call(this);
if (renderRoot instanceof ShadowRoot) {
this.renderOptions ??= {};
this.renderOptions.creationScope = createScopedCreationScope(renderRoot.ownerDocument, customElementRegistry);
}
return renderRoot;
}
});
Object.defineProperty(host, 'createRenderRoot', {
configurable: true,
writable: true,
value(this: ScopedRegistryHost) {
const renderRoot = createRenderRoot.call(this);
if (renderRoot instanceof ShadowRoot) {
this.renderOptions ??= {};
this.renderOptions.creationScope = createScopedCreationScope(renderRoot.ownerDocument, customElementRegistry);
}
return renderRoot;
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@projects/core/src/internal/decorators/scoped-registry.ts` around lines 35 -
45, The Object.defineProperty call that patches host.createRenderRoot should
explicitly set writable: true to harden the override; update the descriptor
passed to Object.defineProperty for the createRenderRoot patch (the function
defined on host, i.e. value(this: ScopedRegistryHost) { ... }) to include
writable: true alongside configurable: true so future reassignments to
createRenderRoot succeed; keep the existing logic that computes renderRoot,
checks ShadowRoot, sets this.renderOptions.creationScope via
createScopedCreationScope(renderRoot.ownerDocument, customElementRegistry), and
returns renderRoot.

}

/** decorator which registers element dependencies with the scoped custom element registry when available */
export function scopedRegistry(): ClassDecorator {
return (target: LegacyDecoratorTarget) => {
Expand All @@ -16,6 +56,7 @@ export function scopedRegistry(): ClassDecorator {
configurable: true,
value: { ...(element.shadowRootOptions ?? { mode: 'open' }), customElementRegistry }
});
attachLitCreationScope(element, customElementRegistry);
}
defineElement(element, customElementRegistry);
};
Expand Down
5 changes: 4 additions & 1 deletion projects/core/src/internal/utils/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export function onListboxActivate(
e.preventDefault();
});

element.addEventListener('pointerup', (e: PointerEvent) => {
// Chrome's LightDismissFromClick runs auto-popover light dismiss from click;
// opening on pointerup can close immediately in the same gesture.
// https://issues.chromium.org/issues/408010435
element.addEventListener('click', (e: PointerEvent) => {
e.preventDefault();

if (!element.disabled) {
Expand Down