diff --git a/change/@microsoft-fast-element-1881142d-a9eb-4665-a3e8-743f9f07d1a6.json b/change/@microsoft-fast-element-1881142d-a9eb-4665-a3e8-743f9f07d1a6.json new file mode 100644 index 00000000000..a59ed0a5459 --- /dev/null +++ b/change/@microsoft-fast-element-1881142d-a9eb-4665-a3e8-743f9f07d1a6.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: use shadowOptions getter override to set hydration attributes", + "packageName": "@microsoft/fast-element", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@microsoft-fast-html-bc3be687-07f6-478d-b3b9-8ff13291b3e8.json b/change/@microsoft-fast-html-bc3be687-07f6-478d-b3b9-8ff13291b3e8.json new file mode 100644 index 00000000000..f22e9923c8d --- /dev/null +++ b/change/@microsoft-fast-html-bc3be687-07f6-478d-b3b9-8ff13291b3e8.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "fix: delegate attribute handling to HydratableElementController", + "packageName": "@microsoft/fast-html", + "email": "863023+radium-v@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/web-components/fast-element/docs/api-report.api.md b/packages/web-components/fast-element/docs/api-report.api.md index ba58341cc24..7aa9d5b3191 100644 --- a/packages/web-components/fast-element/docs/api-report.api.md +++ b/packages/web-components/fast-element/docs/api-report.api.md @@ -240,6 +240,9 @@ export function customElement(nameOrDef: string | PartialFASTElementDefinition): // @public export type DecoratorAttributeConfiguration = Omit; +// @public +export const deferHydrationAttribute = "defer-hydration"; + // @public export interface Disposable { dispose(): void; @@ -282,25 +285,21 @@ export class ElementController exten constructor(element: TElement, definition: FASTElementDefinition); addBehavior(behavior: HostBehavior): void; addStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; - // (undocumented) protected behaviors: Map, number> | null; - // (undocumented) protected bindObservables(): void; connect(): void; - // (undocumented) protected connectBehaviors(): void; get context(): ExecutionContext; readonly definition: FASTElementDefinition; disconnect(): void; - // (undocumented) protected disconnectBehaviors(): void; emit(type: string, detail?: any, options?: Omit): void | boolean; static forCustomElement(element: HTMLElement, override?: boolean): ElementController; + protected hasExistingShadowRoot: boolean; get isBound(): boolean; get isConnected(): boolean; get mainStyles(): ElementStyles | null; set mainStyles(value: ElementStyles | null); - // (undocumented) protected needsInitialization: boolean; onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; onUnbind(behavior: { @@ -308,17 +307,12 @@ export class ElementController exten }): void; removeBehavior(behavior: HostBehavior, force?: boolean): void; removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void; - // (undocumented) protected renderTemplate(template: ElementViewTemplate | null | undefined): void; static setStrategy(strategy: ElementControllerStrategy): void; - // (undocumented) get shadowOptions(): ShadowRootOptions | undefined; set shadowOptions(value: ShadowRootOptions | undefined); readonly source: TElement; get sourceLifetime(): SourceLifetime | undefined; - // Warning: (ae-forgotten-export) The symbol "Stages" needs to be exported by the entry point index.d.ts - // - // (undocumented) protected stage: Stages; get template(): ElementViewTemplate | null; set template(value: ElementViewTemplate | null); @@ -597,16 +591,13 @@ export class HTMLView extends DefaultExecutionCont // @beta export class HydratableElementController extends ElementController { static config(callbacks: HydrationControllerCallbacks): typeof HydratableElementController; - // (undocumented) connect(): void; - // (undocumented) disconnect(): void; - // (undocumented) - static forCustomElement(element: HTMLElement, override?: boolean): ElementController; - // (undocumented) static install(): void; static lifecycleCallbacks?: HydrationControllerCallbacks; protected needsHydration?: boolean; + get shadowOptions(): ShadowRootOptions | undefined; + set shadowOptions(value: ShadowRootOptions | undefined); } // @public (undocumented) @@ -667,6 +658,9 @@ export const Markup: Readonly<{ comment: (id: string) => string; }>; +// @public +export const needsHydrationAttribute = "needs-hydration"; + // @public export interface NodeBehaviorOptions { filter?: ElementsFilter; @@ -922,6 +916,14 @@ export const SpliceStrategySupport: Readonly<{ // @public export type SpliceStrategySupport = (typeof SpliceStrategySupport)[keyof typeof SpliceStrategySupport]; +// @public +export const enum Stages { + connected = 1, + connecting = 0, + disconnected = 3, + disconnecting = 2 +} + // @public export abstract class StatelessAttachedAttributeDirective implements HTMLDirective, ViewBehaviorFactory, ViewBehavior { constructor(options: TOptions); diff --git a/packages/web-components/fast-element/src/components/element-controller.spec.ts b/packages/web-components/fast-element/src/components/element-controller.spec.ts index d2efc7b4fc1..3f43f220306 100644 --- a/packages/web-components/fast-element/src/components/element-controller.spec.ts +++ b/packages/web-components/fast-element/src/components/element-controller.spec.ts @@ -7,7 +7,7 @@ import { html } from "../templating/template.js"; import { uniqueElementName } from "../testing/fixture.js"; import { toHTML } from "../__test__/helpers.js"; import { deferHydrationAttribute, ElementController, HydratableElementController, needsHydrationAttribute } from "./element-controller.js"; -import { FASTElementDefinition, PartialFASTElementDefinition } from "./fast-definitions.js"; +import { FASTElementDefinition, PartialFASTElementDefinition, TemplateOptions } from "./fast-definitions.js"; import { FASTElement } from "./fast-element.js"; import spies from "chai-spies"; @@ -697,10 +697,13 @@ describe("The HydratableElementController", () => { it("should not set a defer-hydration and needs-hydration attribute if the template is set", () => { const { element } = createController(); - HydratableElementController.forCustomElement(element); + HydratableElementController.forCustomElement(element).connect(); - expect(element.getAttribute(deferHydrationAttribute)).to.equal(null); - expect(element.getAttribute(needsHydrationAttribute)).to.equal(null); + setTimeout(() => { + + expect(element.getAttribute(deferHydrationAttribute)).to.equal(null); + expect(element.getAttribute(needsHydrationAttribute)).to.equal(null); + }); }); it("should set a defer-hydration and needs-hydration attribute if the template is not set", () => { const { element } = createController(); @@ -710,9 +713,11 @@ describe("The HydratableElementController", () => { (definition as FASTElementDefinition).template = undefined; (definition as FASTElementDefinition).templateOptions = "defer-and-hydrate"; - HydratableElementController.forCustomElement(element); + HydratableElementController.forCustomElement(element).connect(); - expect(element.getAttribute(deferHydrationAttribute)).to.equal(""); - expect(element.getAttribute(needsHydrationAttribute)).to.equal(""); + setTimeout(() => { + expect(element.getAttribute(deferHydrationAttribute)).to.equal(""); + expect(element.getAttribute(needsHydrationAttribute)).to.equal(""); + }, 0); }); }); diff --git a/packages/web-components/fast-element/src/components/element-controller.ts b/packages/web-components/fast-element/src/components/element-controller.ts index 92bc4d91aa0..9e8880ccd26 100644 --- a/packages/web-components/fast-element/src/components/element-controller.ts +++ b/packages/web-components/fast-element/src/components/element-controller.ts @@ -46,10 +46,18 @@ export interface ElementControllerStrategy { new (element: HTMLElement, definition: FASTElementDefinition): ElementController; } -const enum Stages { +/** + * The various lifecycle stages of an ElementController. + * @public + */ +export const enum Stages { + /** The element is in the process of connecting. */ connecting, + /** The element is connected. */ connected, + /** The element is in the process of disconnecting. */ disconnecting, + /** The element is disconnected. */ disconnected, } @@ -61,24 +69,58 @@ export class ElementController extends PropertyChangeNotifier implements HostController { + /** + * A map of observable properties that were set on the element before upgrade. + */ private boundObservables: Record | null = null; + + /** + * Indicates whether the controller needs to perform initial rendering. + */ protected needsInitialization: boolean = true; - private hasExistingShadowRoot = false; + + /** + * Indicates whether the element has an existing shadow root (e.g. from declarative shadow DOM). + */ + protected hasExistingShadowRoot = false; + + /** + * The template used to render the component. + */ private _template: ElementViewTemplate | null = null; + + /** + * The shadow root options for the component. + */ private _shadowRootOptions: ShadowRootOptions | undefined; + + /** + * The current lifecycle stage of the controller. + */ protected stage: Stages = Stages.disconnected; + /** * A guard against connecting behaviors multiple times * during connect in scenarios where a behavior adds * another behavior during it's connectedCallback */ private guardBehaviorConnection = false; + + /** + * The behaviors associated with the component. + */ protected behaviors: Map, number> | null = null; + /** * Tracks whether behaviors are connected so that * behaviors cant be connected multiple times */ private behaviorsConnected: boolean = false; + + /** + * The main set of styles used for the component, independent of any + * dynamically added styles. + */ private _mainStyles: ElementStyles | null = null; /** @@ -173,6 +215,9 @@ export class ElementController } } + /** + * The shadow root options for the component. + */ public get shadowOptions(): ShadowRootOptions | undefined { return this._shadowRootOptions; } @@ -409,6 +454,9 @@ export class ElementController Observable.notify(this, isConnectedPropertyName); } + /** + * Binds any observables that were set before upgrade. + */ protected bindObservables() { if (this.boundObservables !== null) { const element = this.source; @@ -424,6 +472,9 @@ export class ElementController } } + /** + * Connects any existing behaviors on the associated element. + */ protected connectBehaviors() { if (this.behaviorsConnected === false) { const behaviors = this.behaviors; @@ -440,6 +491,9 @@ export class ElementController } } + /** + * Disconnects any behaviors on the associated element. + */ protected disconnectBehaviors() { if (this.behaviorsConnected === true) { const behaviors = this.behaviors; @@ -514,6 +568,13 @@ export class ElementController return false; } + /** + * Renders the provided template to the element. + * + * @param template - The template to render. + * @remarks + * If `null` is provided, any existing view will be removed. + */ protected renderTemplate(template: ElementViewTemplate | null | undefined): void { // When getting the host to render to, we start by looking // up the shadow root. If there isn't one, then that means @@ -750,7 +811,16 @@ if (ElementStyles.supportsAdoptedStyleSheets) { ElementStyles.setDefaultStrategy(StyleElementStrategy); } +/** + * The attribute used to defer hydration of an element. + * @public + */ export const deferHydrationAttribute = "defer-hydration"; + +/** + * The attribute used to indicate that an element needs hydration. + * @public + */ export const needsHydrationAttribute = "needs-hydration"; /** @@ -793,6 +863,24 @@ export class HydratableElementController< HydratableElementController.hydrationObserverHandler ); + /** + * {@inheritdoc ElementController.shadowOptions} + */ + public get shadowOptions(): ShadowRootOptions | undefined { + return super.shadowOptions; + } + + public set shadowOptions(value: ShadowRootOptions | undefined) { + super.shadowOptions = value; + if ( + this.hasExistingShadowRoot && + this.definition.templateOptions === TemplateOptions.deferAndHydrate + ) { + this.source.toggleAttribute(deferHydrationAttribute, true); + this.source.toggleAttribute(needsHydrationAttribute, true); + } + } + /** * Lifecycle callbacks for hydration events */ @@ -864,28 +952,13 @@ export class HydratableElementController< } } - public static forCustomElement( - element: HTMLElement, - override?: boolean - ): ElementController { - const definition = FASTElementDefinition.getForInstance(element); - - if ( - definition?.templateOptions === TemplateOptions.deferAndHydrate && - !definition.template - ) { - element.toggleAttribute(deferHydrationAttribute, true); - element.toggleAttribute(needsHydrationAttribute, true); - } - - return super.forCustomElement(element, override); - } - + /** + * Runs connected lifecycle behavior on the associated element. + */ public connect() { // Initialize needsHydration on first connect this.needsHydration = - this.needsHydration ?? - this.source.getAttribute(needsHydrationAttribute) !== null; + this.needsHydration ?? this.source.hasAttribute(needsHydrationAttribute); if (this.needsHydration) { HydratableElementController.lifecycleCallbacks?.elementWillHydrate?.( @@ -1009,11 +1082,20 @@ export class HydratableElementController< } } + /** + * Unregisters the hydration observer when the element is disconnected. + */ public disconnect() { super.disconnect(); HydratableElementController.hydrationObserver.unobserve(this.source); } + /** + * Sets the ElementController strategy to HydratableElementController. + * @remarks + * This method is typically called during application startup to enable + * hydration support for FAST elements. + */ public static install() { ElementController.setStrategy(HydratableElementController); } diff --git a/packages/web-components/fast-element/src/index.ts b/packages/web-components/fast-element/src/index.ts index 5b9aaecbaaa..4ee4b100b1d 100644 --- a/packages/web-components/fast-element/src/index.ts +++ b/packages/web-components/fast-element/src/index.ts @@ -160,8 +160,11 @@ export { ValueConverter, } from "./components/attributes.js"; export { + deferHydrationAttribute, ElementController, ElementControllerStrategy, HydratableElementController, type HydrationControllerCallbacks, + needsHydrationAttribute, + Stages, } from "./components/element-controller.js"; diff --git a/packages/web-components/fast-html/src/components/element.ts b/packages/web-components/fast-html/src/components/element.ts index ac09a799a9d..025a9c158aa 100644 --- a/packages/web-components/fast-html/src/components/element.ts +++ b/packages/web-components/fast-html/src/components/element.ts @@ -13,15 +13,10 @@ export function RenderableFASTElement>( ): T { const C = class extends BaseCtor { deferHydration: boolean = true; - needsHydration: boolean = true; constructor(...args: any[]) { super(...args); - if ((this.$fastController as any).hasExistingShadowRoot) { - this.toggleAttribute("defer-hydration", true); - } - (this.prepare?.() ?? Promise.resolve()).then(() => { this.deferHydration = false; }); @@ -39,10 +34,5 @@ export function RenderableFASTElement>( "deferHydration" ); - attr({ mode: "boolean", attribute: "defer-hydration" })( - C.prototype, - "needsHydration" - ); - return C; } diff --git a/packages/web-components/fast-html/src/fixtures/dot-syntax/dot-syntax.fixture.html b/packages/web-components/fast-html/src/fixtures/dot-syntax/dot-syntax.fixture.html index a1e8e69aacb..1ddc96d6195 100644 --- a/packages/web-components/fast-html/src/fixtures/dot-syntax/dot-syntax.fixture.html +++ b/packages/web-components/fast-html/src/fixtures/dot-syntax/dot-syntax.fixture.html @@ -21,9 +21,9 @@ bar - - - + + +