@microsoft/webui-framework 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,7 @@ export declare function getObservableNames(ctor: Function): Set<string>;
36
36
  * only bindings that reference this property.
37
37
  */
38
38
  export declare function observable(target: object, name: string): void;
39
+ export declare function isAttributeProperty(ctor: Function, property: string): boolean;
39
40
  /**
40
41
  * Like {@link observable} but also reflects to/from an HTML attribute
41
42
  * (kebab-case). The decorator patches `observedAttributes` and
@@ -56,5 +57,6 @@ export interface AttrOptions {
56
57
  * Default is string mode (property receives the attribute string value). */
57
58
  mode?: 'boolean';
58
59
  }
60
+ export declare function syncAttrProperties(instance: object, ctor: Function): void;
59
61
  export declare function attr(target: object, name: string): void;
60
62
  export declare function attr(options: AttrOptions): (target: object, name: string) => void;
@@ -84,11 +84,12 @@ export function toKebabCase(str) {
84
84
  }
85
85
  return out;
86
86
  }
87
- /**
88
- * Shared logic for installing a reactive getter/setter on a class prototype.
89
- * The backing value is stored in a private `_prop` field on the instance.
90
- */
91
- function createReactiveProperty(proto, name) {
87
+ const reflectingAttribute = Symbol('webui.reflectingAttribute');
88
+ function parentConstructor(ctor) {
89
+ const parent = Object.getPrototypeOf(ctor);
90
+ return typeof parent === 'function' && parent !== Function.prototype ? parent : null;
91
+ }
92
+ function createReactiveProperty(proto, name, attrDefinition) {
92
93
  const backingKey = `_${name}`;
93
94
  const changedKey = `${name}Changed`;
94
95
  Object.defineProperty(proto, name, {
@@ -97,9 +98,12 @@ function createReactiveProperty(proto, name) {
97
98
  },
98
99
  set(newValue) {
99
100
  const oldValue = this[backingKey];
100
- if (oldValue === newValue)
101
+ if (Object.is(oldValue, newValue))
101
102
  return;
102
103
  this[backingKey] = newValue;
104
+ if (attrDefinition && this['$ready'] === true) {
105
+ reflectPropertyToAttribute(this, attrDefinition, newValue);
106
+ }
103
107
  const cb = this[changedKey];
104
108
  if (typeof cb === 'function') {
105
109
  cb.call(this, oldValue, newValue);
@@ -114,6 +118,54 @@ function createReactiveProperty(proto, name) {
114
118
  configurable: true,
115
119
  });
116
120
  }
121
+ function reflectPropertyToAttribute(instance, definition, value) {
122
+ const element = instance;
123
+ const attrName = definition.attribute;
124
+ if (definition.boolean) {
125
+ const shouldHaveAttribute = Boolean(value);
126
+ if (element.hasAttribute(attrName) === shouldHaveAttribute)
127
+ return;
128
+ setReflectingAttribute(instance, attrName);
129
+ try {
130
+ if (shouldHaveAttribute)
131
+ element.setAttribute(attrName, '');
132
+ else
133
+ element.removeAttribute(attrName);
134
+ }
135
+ finally {
136
+ restoreReflectingAttribute(instance);
137
+ }
138
+ return;
139
+ }
140
+ if (value == null) {
141
+ if (!element.hasAttribute(attrName))
142
+ return;
143
+ setReflectingAttribute(instance, attrName);
144
+ try {
145
+ element.removeAttribute(attrName);
146
+ }
147
+ finally {
148
+ restoreReflectingAttribute(instance);
149
+ }
150
+ return;
151
+ }
152
+ const attrValue = typeof value === 'string' ? value : String(value);
153
+ if (element.getAttribute(attrName) === attrValue)
154
+ return;
155
+ setReflectingAttribute(instance, attrName);
156
+ try {
157
+ element.setAttribute(attrName, attrValue);
158
+ }
159
+ finally {
160
+ restoreReflectingAttribute(instance);
161
+ }
162
+ }
163
+ function setReflectingAttribute(instance, attrName) {
164
+ instance[reflectingAttribute] = attrName;
165
+ }
166
+ function restoreReflectingAttribute(instance) {
167
+ instance[reflectingAttribute] = undefined;
168
+ }
117
169
  // ---------------------------------------------------------------------------
118
170
  // @observable
119
171
  // ---------------------------------------------------------------------------
@@ -124,7 +176,21 @@ const observableRegistry = new WeakMap();
124
176
  */
125
177
  const EMPTY_SET = Object.freeze(new Set());
126
178
  export function getObservableNames(ctor) {
127
- return observableRegistry.get(ctor) ?? EMPTY_SET;
179
+ const names = observableRegistry.get(ctor);
180
+ if (names)
181
+ return names;
182
+ const parent = parentConstructor(ctor);
183
+ return parent ? getObservableNames(parent) : EMPTY_SET;
184
+ }
185
+ function registerObservableProperty(ctor, name) {
186
+ let names = observableRegistry.get(ctor);
187
+ if (!names) {
188
+ const parent = parentConstructor(ctor);
189
+ const inherited = parent ? getObservableNames(parent) : EMPTY_SET;
190
+ names = inherited.size > 0 ? new Set(inherited) : new Set();
191
+ observableRegistry.set(ctor, names);
192
+ }
193
+ names.add(name);
128
194
  }
129
195
  /**
130
196
  * Marks a property as observable. When the value changes the decorator will:
@@ -134,10 +200,7 @@ export function getObservableNames(ctor) {
134
200
  */
135
201
  export function observable(target, name) {
136
202
  const ctor = target.constructor;
137
- if (!observableRegistry.has(ctor)) {
138
- observableRegistry.set(ctor, new Set());
139
- }
140
- observableRegistry.get(ctor).add(name);
203
+ registerObservableProperty(ctor, name);
141
204
  createReactiveProperty(target, name);
142
205
  }
143
206
  // ---------------------------------------------------------------------------
@@ -147,30 +210,74 @@ export function observable(target, name) {
147
210
  * Registry of attribute-name → property-name mappings per constructor.
148
211
  * Used by `attributeChangedCallback` to route attribute changes to properties.
149
212
  */
150
- const attrMap = new WeakMap();
151
- /** Registry of boolean-mode attribute names per constructor. */
152
- const boolAttrs = new WeakMap();
213
+ const attrByAttribute = new WeakMap();
214
+ /** Registry of property-name → attribute metadata, used for mount-time sync. */
215
+ const attrByProperty = new WeakMap();
216
+ function inheritedAttrMap(registry, ctor) {
217
+ let current = parentConstructor(ctor);
218
+ while (current) {
219
+ const map = registry.get(current);
220
+ if (map)
221
+ return map;
222
+ current = parentConstructor(current);
223
+ }
224
+ return undefined;
225
+ }
226
+ function attrDefinitionFor(ctor, attribute) {
227
+ let current = ctor;
228
+ while (current) {
229
+ const definition = attrByAttribute.get(current)?.get(attribute);
230
+ if (definition)
231
+ return definition;
232
+ current = parentConstructor(current);
233
+ }
234
+ return undefined;
235
+ }
236
+ function attrPropertyMapFor(ctor) {
237
+ let current = ctor;
238
+ while (current) {
239
+ const map = attrByProperty.get(current);
240
+ if (map)
241
+ return map;
242
+ current = parentConstructor(current);
243
+ }
244
+ return undefined;
245
+ }
246
+ export function isAttributeProperty(ctor, property) {
247
+ return attrPropertyMapFor(ctor)?.has(property) === true;
248
+ }
153
249
  function applyAttr(target, name, options) {
154
250
  const proto = target;
155
251
  const ctor = proto.constructor;
156
- // 1. Install the reactive getter/setter (same as @observable).
157
- createReactiveProperty(proto, name);
158
- // 2. Register the attribute mapping.
159
252
  const attrName = options?.attribute ?? toKebabCase(name);
160
- if (!attrMap.has(ctor)) {
161
- attrMap.set(ctor, new Map());
253
+ const definition = {
254
+ attribute: attrName,
255
+ property: name,
256
+ boolean: options?.mode === 'boolean',
257
+ };
258
+ // 1. Install the reactive getter/setter (same as @observable), with
259
+ // attribute reflection enabled after the element finishes hydration.
260
+ registerObservableProperty(ctor, name);
261
+ createReactiveProperty(proto, name, definition);
262
+ // 2. Register the attribute mapping.
263
+ let byAttribute = attrByAttribute.get(ctor);
264
+ if (!byAttribute) {
265
+ const inherited = inheritedAttrMap(attrByAttribute, ctor);
266
+ byAttribute = inherited ? new Map(inherited) : new Map();
267
+ attrByAttribute.set(ctor, byAttribute);
162
268
  }
163
- attrMap.get(ctor).set(attrName, name);
164
- // Track boolean-mode attrs.
165
- if (options?.mode === 'boolean') {
166
- if (!boolAttrs.has(ctor)) {
167
- boolAttrs.set(ctor, new Set());
168
- }
169
- boolAttrs.get(ctor).add(attrName);
269
+ byAttribute.set(attrName, definition);
270
+ let byProperty = attrByProperty.get(ctor);
271
+ if (!byProperty) {
272
+ const inherited = inheritedAttrMap(attrByProperty, ctor);
273
+ byProperty = inherited ? new Map(inherited) : new Map();
274
+ attrByProperty.set(ctor, byProperty);
170
275
  }
276
+ byProperty.set(name, definition);
171
277
  // 3. Accumulate observed attributes on the constructor.
172
- if (!ctor._observedAttrs) {
173
- ctor._observedAttrs = [];
278
+ if (!Object.prototype.hasOwnProperty.call(ctor, '_observedAttrs')) {
279
+ const inheritedAttrs = ctor._observedAttrs;
280
+ ctor._observedAttrs = inheritedAttrs ? inheritedAttrs.slice() : [];
174
281
  // Define the static getter that `customElements.define` inspects.
175
282
  Object.defineProperty(ctor, 'observedAttributes', {
176
283
  get() {
@@ -182,11 +289,12 @@ function applyAttr(target, name, options) {
182
289
  const origACB = proto['attributeChangedCallback'];
183
290
  proto['attributeChangedCallback'] = function (attribute, oldVal, newVal) {
184
291
  // Route the attribute change to the corresponding property.
185
- const map = attrMap.get(this.constructor);
186
- const propName = map?.get(attribute);
187
- if (propName !== undefined) {
188
- const isBool = boolAttrs.get(this.constructor)?.has(attribute);
189
- this[propName] = isBool ? newVal !== null : newVal;
292
+ const definition = attrDefinitionFor(this.constructor, attribute);
293
+ if (definition !== undefined &&
294
+ this[reflectingAttribute] !== attribute) {
295
+ this[definition.property] = definition.boolean
296
+ ? newVal !== null
297
+ : newVal;
190
298
  }
191
299
  // Preserve any pre-existing attributeChangedCallback.
192
300
  if (origACB) {
@@ -196,6 +304,15 @@ function applyAttr(target, name, options) {
196
304
  }
197
305
  ctor._observedAttrs.push(attrName);
198
306
  }
307
+ export function syncAttrProperties(instance, ctor) {
308
+ const attrs = attrPropertyMapFor(ctor);
309
+ if (!attrs)
310
+ return;
311
+ const reactiveInstance = instance;
312
+ for (const definition of attrs.values()) {
313
+ reflectPropertyToAttribute(reactiveInstance, definition, reactiveInstance[definition.property]);
314
+ }
315
+ }
199
316
  export function attr(targetOrOptions, name) {
200
317
  if (typeof name === 'string') {
201
318
  applyAttr(targetOrOptions, name);
package/dist/element.d.ts CHANGED
@@ -81,6 +81,14 @@ export declare class WebUIElement extends HTMLElement {
81
81
  * Starts searching from `after` (exclusive) if provided, or from firstChild.
82
82
  */
83
83
  private $findMarker;
84
+ /**
85
+ * Check whether there is non-marker content between a conditional
86
+ * start anchor and its closing marker. Used during SSR hydration to
87
+ * detect server-rendered conditional content even when the runtime
88
+ * condition value has not been set yet (e.g. complex property from a
89
+ * parent repeat binding that hydrates after its children).
90
+ */
91
+ private $hasContentAfterMarker;
84
92
  /**
85
93
  * Find existing SSR text node by mapping template text-node ordinal.
86
94
  *
package/dist/element.js CHANGED
@@ -40,7 +40,7 @@
40
40
  */
41
41
  import { getTemplate } from './template.js';
42
42
  import { hydrationStart, hydrationEnd } from './lifecycle.js';
43
- import { getObservableNames } from './decorators.js';
43
+ import { getObservableNames, isAttributeProperty, syncAttrProperties } from './decorators.js';
44
44
  import { syncRepeat, dotWalk } from './element/diff.js';
45
45
  import { collectItemMarkers, nextElement, findByOrdinal, MARKER_COND_START, MARKER_COND_END, MARKER_REPEAT_START, } from './element/markers.js';
46
46
  import { injectModuleStyle, } from './element/styles.js';
@@ -201,6 +201,7 @@ export class WebUIElement extends HTMLElement {
201
201
  this.$meta = meta;
202
202
  this.$hydrated = true;
203
203
  this.$ready = true;
204
+ syncAttrProperties(this, this.constructor);
204
205
  // Client-created components: flush current attr/observable values
205
206
  // into the freshly-wired template DOM. Call $updateInstance directly
206
207
  // to avoid the $update() path-index build — it will be lazy-built
@@ -299,9 +300,10 @@ export class WebUIElement extends HTMLElement {
299
300
  const state = window.__webui?.state;
300
301
  if (!state || typeof state !== 'object')
301
302
  return;
302
- const names = getObservableNames(this.constructor);
303
+ const ctor = this.constructor;
304
+ const names = getObservableNames(ctor);
303
305
  for (const key of Object.keys(state)) {
304
- if (names.has(key)) {
306
+ if (names.has(key) && !isAttributeProperty(ctor, key)) {
305
307
  // Write to backing field directly — no reactive update yet
306
308
  this[`_${key}`] = state[key];
307
309
  }
@@ -639,8 +641,18 @@ export class WebUIElement extends HTMLElement {
639
641
  }
640
642
  if (marker)
641
643
  lastCondMarker = marker;
642
- if (shown && blockMeta && marker) {
643
- condInstance = this.$hydrateCondContent(condAnchor, blockMeta, scope);
644
+ // SSR hydration: if the marker exists, the server rendered this
645
+ // conditional — hydrate its content regardless of the current
646
+ // condition value. Complex properties from parent bindings may
647
+ // not have arrived yet (the parent hydrates after children), so
648
+ // the condition could evaluate to false even though the server
649
+ // rendered it as true. Trust the SSR DOM and wire it up; the
650
+ // condition will re-evaluate correctly once all data is set.
651
+ const ssrContentPresent = marker && blockMeta && this.$hasContentAfterMarker(condAnchor, MARKER_COND_END);
652
+ if (blockMeta && (shown || ssrContentPresent)) {
653
+ if (marker) {
654
+ condInstance = this.$hydrateCondContent(condAnchor, blockMeta, scope);
655
+ }
644
656
  }
645
657
  // Collect <!--/wc--> end marker for deferred removal.
646
658
  // Do NOT remove here — later phases (repeats, events) still need
@@ -830,9 +842,12 @@ export class WebUIElement extends HTMLElement {
830
842
  // Single-root optimisation: hydrate the element in-place (pathStart=1).
831
843
  const el = nextElement(condAnchor);
832
844
  if (el) {
833
- const inst = this.$hydrate(el, blockMeta, tplDom, scope, 1);
834
- this.$updateInstance(inst);
835
- return inst;
845
+ // Wire bindings only — do NOT call $updateInstance. SSR text
846
+ // nodes already contain correct values; evaluating bindings now
847
+ // would overwrite them with stale data (e.g. a complex property
848
+ // from a parent that hasn't hydrated yet). This is consistent
849
+ // with $mount which also skips $updateInstance for SSR roots.
850
+ return this.$hydrate(el, blockMeta, tplDom, scope, 1);
836
851
  }
837
852
  return null;
838
853
  }
@@ -850,7 +865,7 @@ export class WebUIElement extends HTMLElement {
850
865
  condAnchor.parentNode?.insertBefore(inst.nodes[cn], afterNode.nextSibling);
851
866
  afterNode = inst.nodes[cn];
852
867
  }
853
- this.$updateInstance(inst);
868
+ // Same as above — trust SSR DOM, skip binding evaluation.
854
869
  return inst;
855
870
  }
856
871
  /**
@@ -867,6 +882,23 @@ export class WebUIElement extends HTMLElement {
867
882
  }
868
883
  return null;
869
884
  }
885
+ /**
886
+ * Check whether there is non-marker content between a conditional
887
+ * start anchor and its closing marker. Used during SSR hydration to
888
+ * detect server-rendered conditional content even when the runtime
889
+ * condition value has not been set yet (e.g. complex property from a
890
+ * parent repeat binding that hydrates after its children).
891
+ */
892
+ $hasContentAfterMarker(anchor, endData) {
893
+ let sibling = anchor.nextSibling;
894
+ while (sibling) {
895
+ if (sibling.nodeType === 8 && sibling.data === endData) {
896
+ return false; // reached end marker with no content in between
897
+ }
898
+ return true; // any non-end-marker node = content present
899
+ }
900
+ return false;
901
+ }
870
902
  /**
871
903
  * Find existing SSR text node by mapping template text-node ordinal.
872
904
  *
@@ -1050,36 +1082,66 @@ export class WebUIElement extends HTMLElement {
1050
1082
  const root = dot > -1 ? path.slice(0, dot) : path;
1051
1083
  return observableNames.has(root) ? root : '*';
1052
1084
  };
1053
- const r = this.$root;
1054
- for (const t of r.texts) {
1055
- if (t.parts) {
1056
- for (const p of t.parts) {
1057
- if (typeof p !== 'string')
1058
- ensure(keyFor(p[0])).texts.push(t);
1085
+ const isLocalPath = (path, scope) => {
1086
+ const dot = path.indexOf('.');
1087
+ const root = dot > -1 ? path.slice(0, dot) : path;
1088
+ let current = scope;
1089
+ while (current) {
1090
+ if (current.name === root)
1091
+ return true;
1092
+ current = current.parent;
1093
+ }
1094
+ return false;
1095
+ };
1096
+ const visit = (instance) => {
1097
+ for (const t of instance.texts) {
1098
+ if (t.parts) {
1099
+ for (const p of t.parts) {
1100
+ if (typeof p !== 'string' && !isLocalPath(p[0], t.scope)) {
1101
+ ensure(keyFor(p[0])).texts.push(t);
1102
+ }
1103
+ }
1104
+ }
1105
+ else if (t.path && !isLocalPath(t.path, t.scope)) {
1106
+ ensure(keyFor(t.path)).texts.push(t);
1059
1107
  }
1060
1108
  }
1061
- }
1062
- for (const a of r.attrs) {
1063
- if (a.path)
1064
- ensure(keyFor(a.path)).attrs.push(a);
1065
- if (a.parts) {
1066
- for (const p of a.parts) {
1067
- if (typeof p !== 'string')
1068
- ensure(keyFor(p[0])).attrs.push(a);
1109
+ for (const a of instance.attrs) {
1110
+ if (a.path && !isLocalPath(a.path, a.scope)) {
1111
+ ensure(keyFor(a.path)).attrs.push(a);
1112
+ }
1113
+ if (a.parts) {
1114
+ for (const p of a.parts) {
1115
+ if (typeof p !== 'string' && !isLocalPath(p[0], a.scope)) {
1116
+ ensure(keyFor(p[0])).attrs.push(a);
1117
+ }
1118
+ }
1119
+ }
1120
+ if (a.condition) {
1121
+ for (const p of a.condition[1]) {
1122
+ if (!isLocalPath(p, a.scope))
1123
+ ensure(keyFor(p)).attrs.push(a);
1124
+ }
1125
+ }
1126
+ }
1127
+ for (const c of instance.conds) {
1128
+ for (const p of c.condition[1]) {
1129
+ if (!isLocalPath(p, c.scope))
1130
+ ensure(keyFor(p)).conds.push(c);
1069
1131
  }
1132
+ if (c.instance)
1133
+ visit(c.instance);
1070
1134
  }
1071
- if (a.condition) {
1072
- for (const p of a.condition[1])
1073
- ensure(keyFor(p)).attrs.push(a);
1135
+ for (const rep of instance.repeats) {
1136
+ if (!isLocalPath(rep.collection, rep.scope)) {
1137
+ ensure(keyFor(rep.collection)).repeats.push(rep);
1138
+ }
1139
+ for (let i = 0; i < rep.instances.length; i++) {
1140
+ visit(rep.instances[i].instance);
1141
+ }
1074
1142
  }
1075
- }
1076
- for (const c of r.conds) {
1077
- for (const p of c.condition[1])
1078
- ensure(keyFor(p)).conds.push(c);
1079
- }
1080
- for (const rep of r.repeats) {
1081
- ensure(keyFor(rep.collection)).repeats.push(rep);
1082
- }
1143
+ };
1144
+ visit(this.$root);
1083
1145
  // Store wildcard bindings separately — avoids duplicating them into every path
1084
1146
  const wc = index.get('*');
1085
1147
  if (wc) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/webui-framework",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "description": "WebUI Framework Next — Preact-inspired lightweight Web Component runtime with SSR hydration. 15KB minified, compiled-template path mapping, no hydration markers.",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "@playwright/test": "^1.58.2",
20
20
  "@types/node": "^25.3.5",
21
21
  "typescript": "^5.9.3",
22
- "@microsoft/webui-test-support": "0.0.12"
22
+ "@microsoft/webui-test-support": "0.0.13"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc",