Skip to content

Commit 1c00804

Browse files
authored
fix: use shadowOptions getter override to set hydration attributes (#7205)
* fix: use shadowOptions getter override to set hydration attributes * update documentation * fix: delegate attribute handling to HydratableElementController * Change files * update hydratable element controller tests * patch documentation
1 parent 7196b92 commit 1c00804

File tree

8 files changed

+152
-56
lines changed

8 files changed

+152
-56
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix: use shadowOptions getter override to set hydration attributes",
4+
"packageName": "@microsoft/fast-element",
5+
"email": "[email protected]",
6+
"dependentChangeType": "none"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "fix: delegate attribute handling to HydratableElementController",
4+
"packageName": "@microsoft/fast-html",
5+
"email": "[email protected]",
6+
"dependentChangeType": "none"
7+
}

packages/web-components/fast-element/docs/api-report.api.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ export function customElement(nameOrDef: string | PartialFASTElementDefinition):
240240
// @public
241241
export type DecoratorAttributeConfiguration = Omit<AttributeConfiguration, "property">;
242242

243+
// @public
244+
export const deferHydrationAttribute = "defer-hydration";
245+
243246
// @public
244247
export interface Disposable {
245248
dispose(): void;
@@ -282,43 +285,34 @@ export class ElementController<TElement extends HTMLElement = HTMLElement> exten
282285
constructor(element: TElement, definition: FASTElementDefinition);
283286
addBehavior(behavior: HostBehavior<TElement>): void;
284287
addStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void;
285-
// (undocumented)
286288
protected behaviors: Map<HostBehavior<TElement>, number> | null;
287-
// (undocumented)
288289
protected bindObservables(): void;
289290
connect(): void;
290-
// (undocumented)
291291
protected connectBehaviors(): void;
292292
get context(): ExecutionContext;
293293
readonly definition: FASTElementDefinition;
294294
disconnect(): void;
295-
// (undocumented)
296295
protected disconnectBehaviors(): void;
297296
emit(type: string, detail?: any, options?: Omit<CustomEventInit, "detail">): void | boolean;
298297
static forCustomElement(element: HTMLElement, override?: boolean): ElementController;
298+
protected hasExistingShadowRoot: boolean;
299299
get isBound(): boolean;
300300
get isConnected(): boolean;
301301
get mainStyles(): ElementStyles | null;
302302
set mainStyles(value: ElementStyles | null);
303-
// (undocumented)
304303
protected needsInitialization: boolean;
305304
onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
306305
onUnbind(behavior: {
307306
unbind(controller: ExpressionController<TElement>): any;
308307
}): void;
309308
removeBehavior(behavior: HostBehavior<TElement>, force?: boolean): void;
310309
removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void;
311-
// (undocumented)
312310
protected renderTemplate(template: ElementViewTemplate | null | undefined): void;
313311
static setStrategy(strategy: ElementControllerStrategy): void;
314-
// (undocumented)
315312
get shadowOptions(): ShadowRootOptions | undefined;
316313
set shadowOptions(value: ShadowRootOptions | undefined);
317314
readonly source: TElement;
318315
get sourceLifetime(): SourceLifetime | undefined;
319-
// Warning: (ae-forgotten-export) The symbol "Stages" needs to be exported by the entry point index.d.ts
320-
//
321-
// (undocumented)
322316
protected stage: Stages;
323317
get template(): ElementViewTemplate<TElement> | null;
324318
set template(value: ElementViewTemplate<TElement> | null);
@@ -597,16 +591,13 @@ export class HTMLView<TSource = any, TParent = any> extends DefaultExecutionCont
597591
// @beta
598592
export class HydratableElementController<TElement extends HTMLElement = HTMLElement> extends ElementController<TElement> {
599593
static config(callbacks: HydrationControllerCallbacks): typeof HydratableElementController;
600-
// (undocumented)
601594
connect(): void;
602-
// (undocumented)
603595
disconnect(): void;
604-
// (undocumented)
605-
static forCustomElement(element: HTMLElement, override?: boolean): ElementController<HTMLElement>;
606-
// (undocumented)
607596
static install(): void;
608597
static lifecycleCallbacks?: HydrationControllerCallbacks;
609598
protected needsHydration?: boolean;
599+
get shadowOptions(): ShadowRootOptions | undefined;
600+
set shadowOptions(value: ShadowRootOptions | undefined);
610601
}
611602

612603
// @public (undocumented)
@@ -667,6 +658,9 @@ export const Markup: Readonly<{
667658
comment: (id: string) => string;
668659
}>;
669660

661+
// @public
662+
export const needsHydrationAttribute = "needs-hydration";
663+
670664
// @public
671665
export interface NodeBehaviorOptions<T = any> {
672666
filter?: ElementsFilter;
@@ -922,6 +916,14 @@ export const SpliceStrategySupport: Readonly<{
922916
// @public
923917
export type SpliceStrategySupport = (typeof SpliceStrategySupport)[keyof typeof SpliceStrategySupport];
924918

919+
// @public
920+
export const enum Stages {
921+
connected = 1,
922+
connecting = 0,
923+
disconnected = 3,
924+
disconnecting = 2
925+
}
926+
925927
// @public
926928
export abstract class StatelessAttachedAttributeDirective<TOptions> implements HTMLDirective, ViewBehaviorFactory, ViewBehavior {
927929
constructor(options: TOptions);

packages/web-components/fast-element/src/components/element-controller.spec.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { html } from "../templating/template.js";
77
import { uniqueElementName } from "../testing/fixture.js";
88
import { toHTML } from "../__test__/helpers.js";
99
import { deferHydrationAttribute, ElementController, HydratableElementController, needsHydrationAttribute } from "./element-controller.js";
10-
import { FASTElementDefinition, PartialFASTElementDefinition } from "./fast-definitions.js";
10+
import { FASTElementDefinition, PartialFASTElementDefinition, TemplateOptions } from "./fast-definitions.js";
1111
import { FASTElement } from "./fast-element.js";
1212
import spies from "chai-spies";
1313

@@ -697,10 +697,13 @@ describe("The HydratableElementController", () => {
697697
it("should not set a defer-hydration and needs-hydration attribute if the template is set", () => {
698698
const { element } = createController();
699699

700-
HydratableElementController.forCustomElement(element);
700+
HydratableElementController.forCustomElement(element).connect();
701701

702-
expect(element.getAttribute(deferHydrationAttribute)).to.equal(null);
703-
expect(element.getAttribute(needsHydrationAttribute)).to.equal(null);
702+
setTimeout(() => {
703+
704+
expect(element.getAttribute(deferHydrationAttribute)).to.equal(null);
705+
expect(element.getAttribute(needsHydrationAttribute)).to.equal(null);
706+
});
704707
});
705708
it("should set a defer-hydration and needs-hydration attribute if the template is not set", () => {
706709
const { element } = createController();
@@ -710,9 +713,11 @@ describe("The HydratableElementController", () => {
710713
(definition as FASTElementDefinition).template = undefined;
711714
(definition as FASTElementDefinition).templateOptions = "defer-and-hydrate";
712715

713-
HydratableElementController.forCustomElement(element);
716+
HydratableElementController.forCustomElement(element).connect();
714717

715-
expect(element.getAttribute(deferHydrationAttribute)).to.equal("");
716-
expect(element.getAttribute(needsHydrationAttribute)).to.equal("");
718+
setTimeout(() => {
719+
expect(element.getAttribute(deferHydrationAttribute)).to.equal("");
720+
expect(element.getAttribute(needsHydrationAttribute)).to.equal("");
721+
}, 0);
717722
});
718723
});

packages/web-components/fast-element/src/components/element-controller.ts

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,18 @@ export interface ElementControllerStrategy {
4646
new (element: HTMLElement, definition: FASTElementDefinition): ElementController;
4747
}
4848

49-
const enum Stages {
49+
/**
50+
* The various lifecycle stages of an ElementController.
51+
* @public
52+
*/
53+
export const enum Stages {
54+
/** The element is in the process of connecting. */
5055
connecting,
56+
/** The element is connected. */
5157
connected,
58+
/** The element is in the process of disconnecting. */
5259
disconnecting,
60+
/** The element is disconnected. */
5361
disconnected,
5462
}
5563

@@ -61,24 +69,58 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
6169
extends PropertyChangeNotifier
6270
implements HostController<TElement>
6371
{
72+
/**
73+
* A map of observable properties that were set on the element before upgrade.
74+
*/
6475
private boundObservables: Record<string, any> | null = null;
76+
77+
/**
78+
* Indicates whether the controller needs to perform initial rendering.
79+
*/
6580
protected needsInitialization: boolean = true;
66-
private hasExistingShadowRoot = false;
81+
82+
/**
83+
* Indicates whether the element has an existing shadow root (e.g. from declarative shadow DOM).
84+
*/
85+
protected hasExistingShadowRoot = false;
86+
87+
/**
88+
* The template used to render the component.
89+
*/
6790
private _template: ElementViewTemplate<TElement> | null = null;
91+
92+
/**
93+
* The shadow root options for the component.
94+
*/
6895
private _shadowRootOptions: ShadowRootOptions | undefined;
96+
97+
/**
98+
* The current lifecycle stage of the controller.
99+
*/
69100
protected stage: Stages = Stages.disconnected;
101+
70102
/**
71103
* A guard against connecting behaviors multiple times
72104
* during connect in scenarios where a behavior adds
73105
* another behavior during it's connectedCallback
74106
*/
75107
private guardBehaviorConnection = false;
108+
109+
/**
110+
* The behaviors associated with the component.
111+
*/
76112
protected behaviors: Map<HostBehavior<TElement>, number> | null = null;
113+
77114
/**
78115
* Tracks whether behaviors are connected so that
79116
* behaviors cant be connected multiple times
80117
*/
81118
private behaviorsConnected: boolean = false;
119+
120+
/**
121+
* The main set of styles used for the component, independent of any
122+
* dynamically added styles.
123+
*/
82124
private _mainStyles: ElementStyles | null = null;
83125

84126
/**
@@ -173,6 +215,9 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
173215
}
174216
}
175217

218+
/**
219+
* The shadow root options for the component.
220+
*/
176221
public get shadowOptions(): ShadowRootOptions | undefined {
177222
return this._shadowRootOptions;
178223
}
@@ -409,6 +454,9 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
409454
Observable.notify(this, isConnectedPropertyName);
410455
}
411456

457+
/**
458+
* Binds any observables that were set before upgrade.
459+
*/
412460
protected bindObservables() {
413461
if (this.boundObservables !== null) {
414462
const element = this.source;
@@ -424,6 +472,9 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
424472
}
425473
}
426474

475+
/**
476+
* Connects any existing behaviors on the associated element.
477+
*/
427478
protected connectBehaviors() {
428479
if (this.behaviorsConnected === false) {
429480
const behaviors = this.behaviors;
@@ -440,6 +491,9 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
440491
}
441492
}
442493

494+
/**
495+
* Disconnects any behaviors on the associated element.
496+
*/
443497
protected disconnectBehaviors() {
444498
if (this.behaviorsConnected === true) {
445499
const behaviors = this.behaviors;
@@ -514,6 +568,13 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
514568
return false;
515569
}
516570

571+
/**
572+
* Renders the provided template to the element.
573+
*
574+
* @param template - The template to render.
575+
* @remarks
576+
* If `null` is provided, any existing view will be removed.
577+
*/
517578
protected renderTemplate(template: ElementViewTemplate | null | undefined): void {
518579
// When getting the host to render to, we start by looking
519580
// up the shadow root. If there isn't one, then that means
@@ -750,7 +811,16 @@ if (ElementStyles.supportsAdoptedStyleSheets) {
750811
ElementStyles.setDefaultStrategy(StyleElementStrategy);
751812
}
752813

814+
/**
815+
* The attribute used to defer hydration of an element.
816+
* @public
817+
*/
753818
export const deferHydrationAttribute = "defer-hydration";
819+
820+
/**
821+
* The attribute used to indicate that an element needs hydration.
822+
* @public
823+
*/
754824
export const needsHydrationAttribute = "needs-hydration";
755825

756826
/**
@@ -793,6 +863,24 @@ export class HydratableElementController<
793863
HydratableElementController.hydrationObserverHandler
794864
);
795865

866+
/**
867+
* {@inheritdoc ElementController.shadowOptions}
868+
*/
869+
public get shadowOptions(): ShadowRootOptions | undefined {
870+
return super.shadowOptions;
871+
}
872+
873+
public set shadowOptions(value: ShadowRootOptions | undefined) {
874+
super.shadowOptions = value;
875+
if (
876+
this.hasExistingShadowRoot &&
877+
this.definition.templateOptions === TemplateOptions.deferAndHydrate
878+
) {
879+
this.source.toggleAttribute(deferHydrationAttribute, true);
880+
this.source.toggleAttribute(needsHydrationAttribute, true);
881+
}
882+
}
883+
796884
/**
797885
* Lifecycle callbacks for hydration events
798886
*/
@@ -864,28 +952,13 @@ export class HydratableElementController<
864952
}
865953
}
866954

867-
public static forCustomElement(
868-
element: HTMLElement,
869-
override?: boolean
870-
): ElementController<HTMLElement> {
871-
const definition = FASTElementDefinition.getForInstance(element);
872-
873-
if (
874-
definition?.templateOptions === TemplateOptions.deferAndHydrate &&
875-
!definition.template
876-
) {
877-
element.toggleAttribute(deferHydrationAttribute, true);
878-
element.toggleAttribute(needsHydrationAttribute, true);
879-
}
880-
881-
return super.forCustomElement(element, override);
882-
}
883-
955+
/**
956+
* Runs connected lifecycle behavior on the associated element.
957+
*/
884958
public connect() {
885959
// Initialize needsHydration on first connect
886960
this.needsHydration =
887-
this.needsHydration ??
888-
this.source.getAttribute(needsHydrationAttribute) !== null;
961+
this.needsHydration ?? this.source.hasAttribute(needsHydrationAttribute);
889962

890963
if (this.needsHydration) {
891964
HydratableElementController.lifecycleCallbacks?.elementWillHydrate?.(
@@ -1009,11 +1082,20 @@ export class HydratableElementController<
10091082
}
10101083
}
10111084

1085+
/**
1086+
* Unregisters the hydration observer when the element is disconnected.
1087+
*/
10121088
public disconnect() {
10131089
super.disconnect();
10141090
HydratableElementController.hydrationObserver.unobserve(this.source);
10151091
}
10161092

1093+
/**
1094+
* Sets the ElementController strategy to HydratableElementController.
1095+
* @remarks
1096+
* This method is typically called during application startup to enable
1097+
* hydration support for FAST elements.
1098+
*/
10171099
public static install() {
10181100
ElementController.setStrategy(HydratableElementController);
10191101
}

packages/web-components/fast-element/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,11 @@ export {
160160
ValueConverter,
161161
} from "./components/attributes.js";
162162
export {
163+
deferHydrationAttribute,
163164
ElementController,
164165
ElementControllerStrategy,
165166
HydratableElementController,
166167
type HydrationControllerCallbacks,
168+
needsHydrationAttribute,
169+
Stages,
167170
} from "./components/element-controller.js";

0 commit comments

Comments
 (0)