Skip to content

Conversation

@OS-paulvisciano
Copy link
Contributor

@OS-paulvisciano OS-paulvisciano commented Jan 15, 2026

Exposing ReactiveController and Host publicly to make it easier to implement the pattern and also provide a common interface community members can use to create and share Controllers

What is the current behavior?

The classes were internal to the tests

What is the new behavior?

Exposed as part of the public API

Documentation

For more info on Reactive Controllers

https://stenciljs.com/docs/extends

Does this introduce a breaking change?

  • Yes
  • No

Testing

Existing tests using the Reactive controller pattern now import the implementation from core and pass.

@OS-paulvisciano OS-paulvisciano requested a review from a team as a code owner January 15, 2026 09:00
Copilot AI review requested due to automatic review settings January 15, 2026 09:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request moves the ReactiveController interface and ReactiveControllerHost class from internal test files into the public Stencil Core API. This enables developers to use the reactive controller pattern for component composition, similar to patterns found in frameworks like Lit.

Changes:

  • Moved ReactiveController and ReactiveControllerHost from test-local implementations to src/runtime/reactive-controller.ts as public exports
  • Updated all test files to import from @stencil/core instead of local ./reactive-controller-host.js files
  • Added proper TypeScript type declarations in stencil-public-runtime.ts

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/runtime/reactive-controller.ts Implements the ReactiveController interface and ReactiveControllerHost class with lifecycle hooks
src/runtime/index.ts Exports ReactiveController and ReactiveControllerHost from the runtime module
src/declarations/stencil-public-runtime.ts Adds public TypeScript type declarations for the reactive controller API
src/internal/stencil-core/index.d.ts Exports the new public types and classes in the main API surface
src/compiler/transformers/update-stencil-core-import.ts Adds ReactiveController and ReactiveControllerHost to KEEP_IMPORTS list
src/hydrate/platform/index.ts Re-exports the new API for server-side rendering support
test/wdio/ts-target/extends-composition-scaling/reactive-controller-host.ts Deleted - local implementation replaced by public API
test/wdio/ts-target/extends-via-host/mouse-controller.ts Updated import to use @stencil/core
test/wdio/ts-target/extends-via-host/cmp.tsx Updated import to use @stencil/core
test/wdio/ts-target/extends-composition-scaling/validation-controller.ts Updated import to use @stencil/core
test/wdio/ts-target/extends-composition-scaling/text-input-cmp.tsx Updated import to use @stencil/core
test/wdio/ts-target/extends-composition-scaling/radio-group-cmp.tsx Updated import to use @stencil/core
test/wdio/ts-target/extends-composition-scaling/focus-controller.ts Updated import to use @stencil/core
test/wdio/ts-target/extends-composition-scaling/checkbox-group-cmp.tsx Updated import to use @stencil/core
Comments suppressed due to low confidence (1)

src/runtime/reactive-controller.ts:62

  • Extra blank line at end of file should be removed.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@gnbm gnbm requested review from gnbm and johnjenkins January 15, 2026 15:42
Copy link
Contributor

@gnbm gnbm left a comment

Choose a reason for hiding this comment

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

@OS-paulvisciano Two checks are failing:

And ideally it would be good practice to use signed commits

Copilot AI review requested due to automatic review settings January 20, 2026 11:09
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI review requested due to automatic review settings January 20, 2026 12:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 49 out of 54 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • test/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@OS-paulvisciano OS-paulvisciano force-pushed the feat/reactive-controllers branch 2 times, most recently from add9245 to fbd7300 Compare January 20, 2026 12:34
* Components extending this class can use the composition pattern to share
* stateful logic via reactive controllers.
*
* Known Limitation: Components extending ReactiveControllerHost cannot use
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't understand this limitation (/ is it acceptable?) - can you explain it to me?

<Host> is just a jsx helper, allowing devs to target and sprout attributes on the output <custom-element> tag.

Copy link
Contributor

@paulvisciano paulvisciano Jan 21, 2026

Choose a reason for hiding this comment

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

When a component extends ReactiveControllerHost and uses as the root element, Stencil's runtime expects the component instance to be the actual custom element (HTMLElement), but ReactiveControllerHost doesn't satisfy that contract, leading to initialization failures. When I tried to make the ReactiveControllerHost extend HTMLElement I hit some initialization issues

Runtime error : custom-elements#externalruntime: undefined
When ReactiveControllerHost extends HTMLElement and is imported from @stencil/core, the Stencil compiler's lazy loading mechanism fails to properly initialize component instances. The runtime thinks these are external custom-element components that need the externalRuntime flag, but they're actually regular Stencil components.

If you have tips on how to address this it would be awesome.

Copy link
Contributor

Choose a reason for hiding this comment

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

@johnjenkins thoughts on this?

@johnjenkins
Copy link
Contributor

@OS-paulvisciano - I believe this is the file you'll need to add to

Moves ReactiveController interface and ReactiveControllerHost class from
test utilities to @stencil/core runtime, making them publicly available
for component composition patterns.

This enables developers to:
- Create reusable reactive controllers that hook into component lifecycle
- Compose multiple controllers in a single component
- Share stateful logic across components without inheritance

Components extending ReactiveControllerHost can use addController() to
register controllers that automatically receive lifecycle callbacks
(hostConnected, hostDisconnected, hostWillLoad, etc.) and can trigger
updates via requestUpdate().

Known Limitation: Components extending ReactiveControllerHost cannot use
<Host> as their root element since ReactiveControllerHost does not extend
HTMLElement. Components must return regular elements (e.g., <div>) as root.

Changes:
- Add ReactiveController interface to src/runtime/reactive-controller.ts
- Add ReactiveControllerHost class to src/runtime/reactive-controller.ts
- Export from src/runtime/index.ts and src/hydrate/platform/index.ts
- Export from src/internal/stencil-core/index.d.ts for public API
- Add type declarations to src/declarations/stencil-public-runtime.ts
- Update transformer to preserve ReactiveControllerHost in imports
- Update test components to import from @stencil/core
Copilot AI review requested due to automatic review settings January 21, 2026 13:20
@OS-paulvisciano OS-paulvisciano force-pushed the feat/reactive-controllers branch from fbd7300 to 6d0ea49 Compare January 21, 2026 13:20
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 12 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +61 to +78
componentWillLoad() {
this.controllers.forEach((controller) => controller.hostWillLoad?.());
}

componentDidLoad() {
this.controllers.forEach((controller) => controller.hostDidLoad?.());
}

componentWillRender() {
this.controllers.forEach((controller) => controller.hostWillRender?.());
}

componentDidRender() {
this.controllers.forEach((controller) => controller.hostDidRender?.());
}

componentWillUpdate() {
this.controllers.forEach((controller) => controller.hostWillUpdate?.());
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The lifecycle methods componentWillLoad, componentWillRender, and componentWillUpdate do not properly handle Promise return values from controller hooks. The controller interface allows these methods to return Promise<void> | void, but the host implementation doesn't await these promises. This could lead to race conditions where lifecycle execution continues before async controller operations complete. Consider using Promise.all() to await all controller promises before proceeding.

Suggested change
componentWillLoad() {
this.controllers.forEach((controller) => controller.hostWillLoad?.());
}
componentDidLoad() {
this.controllers.forEach((controller) => controller.hostDidLoad?.());
}
componentWillRender() {
this.controllers.forEach((controller) => controller.hostWillRender?.());
}
componentDidRender() {
this.controllers.forEach((controller) => controller.hostDidRender?.());
}
componentWillUpdate() {
this.controllers.forEach((controller) => controller.hostWillUpdate?.());
componentWillLoad(): Promise<void> | void {
const promises: Promise<void>[] = [];
this.controllers.forEach((controller) => {
const result = controller.hostWillLoad?.();
if (result instanceof Promise) {
promises.push(result);
}
});
if (promises.length > 0) {
return Promise.all(promises).then(() => undefined);
}
}
componentDidLoad() {
this.controllers.forEach((controller) => controller.hostDidLoad?.());
}
componentWillRender(): Promise<void> | void {
const promises: Promise<void>[] = [];
this.controllers.forEach((controller) => {
const result = controller.hostWillRender?.();
if (result instanceof Promise) {
promises.push(result);
}
});
if (promises.length > 0) {
return Promise.all(promises).then(() => undefined);
}
}
componentDidRender() {
this.controllers.forEach((controller) => controller.hostDidRender?.());
}
componentWillUpdate(): Promise<void> | void {
const promises: Promise<void>[] = [];
this.controllers.forEach((controller) => {
const result = controller.hostWillUpdate?.();
if (result instanceof Promise) {
promises.push(result);
}
});
if (promises.length > 0) {
return Promise.all(promises).then(() => undefined);
}

Copilot uses AI. Check for mistakes.
Comment on lines +673 to +677
componentWillLoad(): void;
componentDidLoad(): void;
componentWillRender(): void;
componentDidRender(): void;
componentWillUpdate(): void;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The type declaration for lifecycle methods doesn't match the implementation. The componentWillLoad, componentWillRender, and componentWillUpdate methods should be declared with return type Promise<void> | void to match the ComponentInterface and properly handle async operations. Currently they're declared as returning void only.

Suggested change
componentWillLoad(): void;
componentDidLoad(): void;
componentWillRender(): void;
componentDidRender(): void;
componentWillUpdate(): void;
componentWillLoad(): Promise<void> | void;
componentDidLoad(): void;
componentWillRender(): Promise<void> | void;
componentDidRender(): void;
componentWillUpdate(): Promise<void> | void;

Copilot uses AI. Check for mistakes.
Comment on lines +642 to +652
* Interface for reactive controllers that can be attached to a ReactiveControllerHost.
* Controllers implement lifecycle hooks that are called by the host during component lifecycle.
*/
export interface ReactiveController {
hostConnected?(): void;
hostDisconnected?(): void;
hostWillLoad?(): Promise<void> | void;
hostDidLoad?(): void;
hostWillRender?(): Promise<void> | void;
hostDidRender?(): void;
hostWillUpdate?(): Promise<void> | void;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The documentation for the ReactiveController interface is minimal. Consider adding JSDoc comments explaining the purpose and usage of each lifecycle hook method, similar to how ComponentInterface methods are documented elsewhere in the codebase. This would help developers understand when each hook is called and what they should be used for.

Suggested change
* Interface for reactive controllers that can be attached to a ReactiveControllerHost.
* Controllers implement lifecycle hooks that are called by the host during component lifecycle.
*/
export interface ReactiveController {
hostConnected?(): void;
hostDisconnected?(): void;
hostWillLoad?(): Promise<void> | void;
hostDidLoad?(): void;
hostWillRender?(): Promise<void> | void;
hostDidRender?(): void;
hostWillUpdate?(): Promise<void> | void;
* Interface for reactive controllers that can be attached to a {@link ReactiveControllerHost}.
*
* Controllers implement lifecycle hooks that are called by the host at specific points in the
* component's lifecycle. These hooks mirror the {@link ComponentInterface} lifecycle methods
* and allow cross‑cutting logic (such as state management, subscriptions, or side‑effects)
* to be encapsulated and reused across multiple components.
*/
export interface ReactiveController {
/**
* Called when the host component is connected to the DOM.
*
* This corresponds to the host's `connectedCallback`. Use this hook to perform setup
* work that depends on the host being attached, such as registering event listeners
* on global objects or starting subscriptions.
*/
hostConnected?(): void;
/**
* Called when the host component is disconnected from the DOM.
*
* This corresponds to the host's `disconnectedCallback`. Use this hook to clean up
* any work started in {@link ReactiveController.hostConnected}, such as removing
* event listeners, cancelling timers, or disposing of subscriptions.
*/
hostDisconnected?(): void;
/**
* Called before the host component has loaded and before its first render.
*
* This corresponds to the host's `componentWillLoad`. Use this hook to perform
* asynchronous or synchronous setup that should complete before the initial
* render, such as fetching data or initializing controller state.
*
* This hook is only called once during the lifetime of the host.
*/
hostWillLoad?(): Promise<void> | void;
/**
* Called after the host component has loaded and completed its first render.
*
* This corresponds to the host's `componentDidLoad`. Use this hook for logic that
* depends on the host's initial DOM being rendered, such as measuring layout or
* interacting with rendered child elements.
*
* This hook is only called once during the lifetime of the host.
*/
hostDidLoad?(): void;
/**
* Called before the host component is about to render.
*
* This corresponds to the host's `componentWillRender`. Use this hook to perform
* last‑minute state adjustments before a render is triggered. It may be called
* multiple times over the life of the host as it re‑renders.
*/
hostWillRender?(): Promise<void> | void;
/**
* Called after the host component has just rendered.
*
* This corresponds to the host's `componentDidRender`. Use this hook to perform
* work that depends on the latest rendered DOM, such as DOM measurements or
* non‑reactive side‑effects.
*
* It may be called multiple times over the life of the host as it re‑renders.
*/
hostDidRender?(): void;
/**
* Called before the host component updates and re‑renders due to a state or prop change.
*
* This corresponds to the host's `componentWillUpdate`. Use this hook to react to
* changes before the DOM is updated. It may be called multiple times throughout the
* life of the host as it updates.
*
* This hook is not called for the initial render.
*/
hostWillUpdate?(): Promise<void> | void;
/**
* Called after the host component has updated and re‑rendered.
*
* This corresponds to the host's `componentDidUpdate`. Use this hook to perform
* side‑effects that depend on the updated DOM or state after an update.
*
* It may be called multiple times throughout the life of the host as it updates,
* and is not called for the initial render.
*/

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +11
export interface ReactiveController {
hostConnected?(): void;
hostDisconnected?(): void;
hostWillLoad?(): Promise<void> | void;
hostDidLoad?(): void;
hostWillRender?(): Promise<void> | void;
hostDidRender?(): void;
hostWillUpdate?(): Promise<void> | void;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The ReactiveController interface definition lacks JSDoc comments for individual methods. Consider documenting each lifecycle hook to explain when it's called and what it should be used for. For example, clarify that hostConnected corresponds to connectedCallback, hostWillLoad to componentWillLoad, etc.

Suggested change
export interface ReactiveController {
hostConnected?(): void;
hostDisconnected?(): void;
hostWillLoad?(): Promise<void> | void;
hostDidLoad?(): void;
hostWillRender?(): Promise<void> | void;
hostDidRender?(): void;
hostWillUpdate?(): Promise<void> | void;
export interface ReactiveController {
/**
* Called when the host component's `connectedCallback` is invoked.
* Use this to perform setup that depends on the host being connected to the DOM.
*/
hostConnected?(): void;
/**
* Called when the host component's `disconnectedCallback` is invoked.
* Use this to perform cleanup when the host is removed from the DOM.
*/
hostDisconnected?(): void;
/**
* Called when the host component's `componentWillLoad` lifecycle runs.
* Use this to perform work before the host first renders.
*/
hostWillLoad?(): Promise<void> | void;
/**
* Called when the host component's `componentDidLoad` lifecycle runs.
* Use this to perform work after the host has finished its initial render.
*/
hostDidLoad?(): void;
/**
* Called when the host component's `componentWillRender` lifecycle runs.
* Use this to react to state changes before each render.
*/
hostWillRender?(): Promise<void> | void;
/**
* Called when the host component's `componentDidRender` lifecycle runs.
* Use this to perform work after each render.
*/
hostDidRender?(): void;
/**
* Called when the host component's `componentWillUpdate` lifecycle runs.
* Use this to react to prop or state changes before an update.
*/
hostWillUpdate?(): Promise<void> | void;
/**
* Called when the host component's `componentDidUpdate` lifecycle runs.
* Use this to perform work after an update has been applied.
*/

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +82
connectedCallback() {
this.controllers.forEach((controller) => controller.hostConnected?.());
}

disconnectedCallback() {
this.controllers.forEach((controller) => controller.hostDisconnected?.());
}

componentWillLoad() {
this.controllers.forEach((controller) => controller.hostWillLoad?.());
}

componentDidLoad() {
this.controllers.forEach((controller) => controller.hostDidLoad?.());
}

componentWillRender() {
this.controllers.forEach((controller) => controller.hostWillRender?.());
}

componentDidRender() {
this.controllers.forEach((controller) => controller.hostDidRender?.());
}

componentWillUpdate() {
this.controllers.forEach((controller) => controller.hostWillUpdate?.());
}

componentDidUpdate() {
this.controllers.forEach((controller) => controller.hostDidUpdate?.());
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The lifecycle methods in ReactiveControllerHost don't include error handling when invoking controller hooks. If a controller's lifecycle hook throws an error, it could prevent other controllers from executing their hooks. Consider wrapping controller hook calls in try-catch blocks to ensure one controller's error doesn't affect others.

Suggested change
connectedCallback() {
this.controllers.forEach((controller) => controller.hostConnected?.());
}
disconnectedCallback() {
this.controllers.forEach((controller) => controller.hostDisconnected?.());
}
componentWillLoad() {
this.controllers.forEach((controller) => controller.hostWillLoad?.());
}
componentDidLoad() {
this.controllers.forEach((controller) => controller.hostDidLoad?.());
}
componentWillRender() {
this.controllers.forEach((controller) => controller.hostWillRender?.());
}
componentDidRender() {
this.controllers.forEach((controller) => controller.hostDidRender?.());
}
componentWillUpdate() {
this.controllers.forEach((controller) => controller.hostWillUpdate?.());
}
componentDidUpdate() {
this.controllers.forEach((controller) => controller.hostDidUpdate?.());
private invokeControllerHook(hook: keyof ReactiveController) {
this.controllers.forEach((controller) => {
const callback = controller[hook];
if (typeof callback === 'function') {
try {
callback.call(controller);
} catch (error) {
// Ensure one controller's error doesn't prevent others from running
console.error(
`Error in ReactiveController ${String(hook)} hook:`,
error,
);
}
}
});
}
connectedCallback() {
this.invokeControllerHook('hostConnected');
}
disconnectedCallback() {
this.invokeControllerHook('hostDisconnected');
}
componentWillLoad() {
this.invokeControllerHook('hostWillLoad');
}
componentDidLoad() {
this.invokeControllerHook('hostDidLoad');
}
componentWillRender() {
this.invokeControllerHook('hostWillRender');
}
componentDidRender() {
this.invokeControllerHook('hostDidRender');
}
componentWillUpdate() {
this.invokeControllerHook('hostWillUpdate');
}
componentDidUpdate() {
this.invokeControllerHook('hostDidUpdate');

Copilot uses AI. Check for mistakes.
* ```
*/
export class ReactiveControllerHost implements ComponentInterface {
controllers = new Set<ReactiveController>();
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The controllers property is declared as public without any access modifier or documentation. Consider making it readonly to prevent external modification of the Set, as the API provides addController and removeController methods for managing controllers. This would ensure the Set is only modified through the intended API surface.

Suggested change
controllers = new Set<ReactiveController>();
readonly controllers = new Set<ReactiveController>();

Copilot uses AI. Check for mistakes.
* regular element (like `<div>`) as the root.
*/
export declare class ReactiveControllerHost implements ComponentInterface {
controllers: Set<ReactiveController>;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The controllers property in the type declaration should be declared as readonly to match the recommendation for the implementation and prevent external modification of the Set. The API provides addController and removeController methods which should be the only way to manage controllers.

Suggested change
controllers: Set<ReactiveController>;
readonly controllers: Set<ReactiveController>;

Copilot uses AI. Check for mistakes.
@gnbm gnbm self-requested a review January 26, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants