-
-
Notifications
You must be signed in to change notification settings - Fork 825
Move ReactiveController and Host to core #6548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Move ReactiveController and Host to core #6548
Conversation
There was a problem hiding this 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
ReactiveControllerandReactiveControllerHostfrom test-local implementations tosrc/runtime/reactive-controller.tsas public exports - Updated all test files to import from
@stencil/coreinstead of local./reactive-controller-host.jsfiles - 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
left a comment
There was a problem hiding this 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:
- CI / Lint and Format / Check (pull_request)
CI / Lint and Format / Check (pull_request)Failing after 46s
Required - CI / WebdriverIO Tests / Run WebdriverIO Component Tests (CHROME) (pull_request)
CI / WebdriverIO Tests / Run WebdriverIO Component Tests (CHROME) (pull_request)
And ideally it would be good practice to use signed commits
There was a problem hiding this 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.
There was a problem hiding this 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.
add9245 to
fbd7300
Compare
| * Components extending this class can use the composition pattern to share | ||
| * stateful logic via reactive controllers. | ||
| * | ||
| * Known Limitation: Components extending ReactiveControllerHost cannot use |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@johnjenkins thoughts on this?
|
@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
fbd7300 to
6d0ea49
Compare
There was a problem hiding this 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.
| 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?.()); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| 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); | |
| } |
| componentWillLoad(): void; | ||
| componentDidLoad(): void; | ||
| componentWillRender(): void; | ||
| componentDidRender(): void; | ||
| componentWillUpdate(): void; |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| 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; |
| * 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; |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| * 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. | |
| */ |
| export interface ReactiveController { | ||
| hostConnected?(): void; | ||
| hostDisconnected?(): void; | ||
| hostWillLoad?(): Promise<void> | void; | ||
| hostDidLoad?(): void; | ||
| hostWillRender?(): Promise<void> | void; | ||
| hostDidRender?(): void; | ||
| hostWillUpdate?(): Promise<void> | void; |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| 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. | |
| */ |
| 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?.()); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| 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'); |
| * ``` | ||
| */ | ||
| export class ReactiveControllerHost implements ComponentInterface { | ||
| controllers = new Set<ReactiveController>(); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| controllers = new Set<ReactiveController>(); | |
| readonly controllers = new Set<ReactiveController>(); |
| * regular element (like `<div>`) as the root. | ||
| */ | ||
| export declare class ReactiveControllerHost implements ComponentInterface { | ||
| controllers: Set<ReactiveController>; |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| controllers: Set<ReactiveController>; | |
| readonly controllers: Set<ReactiveController>; |
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?
Testing
Existing tests using the Reactive controller pattern now import the implementation from core and pass.