@symbo.ls/element 3.14.0 → 3.14.2

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@symbo.ls/element",
3
- "version": "3.14.0",
4
- "description": "Core element creation, update, and disposal engine for smbls v4",
3
+ "version": "3.14.2",
4
+ "description": "Core element creation, update, and disposal engine for smbls v3.14",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "module": "./index.js",
@@ -16,6 +16,9 @@
16
16
  "css-in-props": "^3.14.0",
17
17
  "attrs-in-props": "^3.14.0"
18
18
  },
19
- "files": ["src", "index.js"],
19
+ "files": [
20
+ "src",
21
+ "index.js"
22
+ ],
20
23
  "license": "CC-BY-NC-4.0"
21
24
  }
package/src/cache.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  // Node caching - cloneNode from cached prototypes instead of createElement
4
- // This is already a v3 optimization that works well -- preserved in v4
4
+ // This is already a v3 optimization that works well -- preserved in v3.14
5
5
 
6
6
  const SVG_NS = 'http://www.w3.org/2000/svg'
7
7
 
package/src/create.js CHANGED
@@ -90,6 +90,22 @@ export function create(definition, parent, key, options = {}, attachOptions) {
90
90
  const components = element.context?.components
91
91
  if (components && (components[baseName] || components['smbls.' + baseName])) {
92
92
  definition = { extends: baseName, ...definition }
93
+ } else if (
94
+ // FRAMEWORK-5: dev-mode warning. A key shaped like a multi-instance
95
+ // suffix (e.g. `Link_1`) clearly intends to auto-extend a base
96
+ // component, so silently rendering as a bare div is the kind of
97
+ // failure that takes a click test to discover. Warn loudly in dev
98
+ // so the typo / missing import is visible at mount time.
99
+ components &&
100
+ process.env.NODE_ENV !== 'production' &&
101
+ /^[A-Z]\w*_\d+$/.test(key)
102
+ ) {
103
+ console.warn(
104
+ `[smbls] auto-extend: key "${key}" looks like a multi-instance ` +
105
+ `(${baseName}_N) but neither "${baseName}" nor "smbls.${baseName}" ` +
106
+ `is registered in context.components — element will render as a ` +
107
+ `plain <div>. Register the base component or rename the key.`
108
+ )
93
109
  }
94
110
  }
95
111
  }
@@ -155,12 +171,34 @@ export function create(definition, parent, key, options = {}, attachOptions) {
155
171
  if (definition.classlist !== undefined) element.classlist = definition.classlist
156
172
  if (definition.text !== undefined) element.text = definition.text
157
173
  if (definition.html !== undefined) element.html = definition.html
158
- if (definition.scope !== undefined) {
159
- element.scope = parent?.scope
160
- ? Object.assign(Object.create(parent.scope), definition.scope)
161
- : definition.scope
162
- } else if (parent?.scope) {
163
- element.scope = Object.create(parent.scope)
174
+ // The scope chain at any element resolves keys in this order:
175
+ // own (definition.scope) → parent.scope → … → root.scope → context.globalScope
176
+ // Direct reads like `el.scope.LANG_META` walk the prototype chain, so a
177
+ // component that doesn't declare its own scope still sees parent scopes
178
+ // and the project's globalScope as fallbacks. Without `globalScope` at the
179
+ // bottom of the chain, every project pattern that puts shared constants
180
+ // in `globalScope.js` and reads them via `el.scope.X` was silently broken
181
+ // — the user's reactive function got `undefined` and either threw or
182
+ // rendered nothing. The frank-rewriter's `__scope` helper already chains
183
+ // through globalScope; this brings the runtime `el.scope` to parity.
184
+ // Capture scope set by the extends chain (applyExtends ran above) BEFORE
185
+ // we rebuild element.scope. Otherwise scope blocks declared on the base
186
+ // component (e.g. `Navbar.scope = { LANG_META }`) get blown away when
187
+ // a consumer extends without redeclaring scope (`AppNavbar: { extends:
188
+ // 'Navbar' }`). FA-L24.
189
+ const inheritedFromExtends = element.scope && typeof element.scope === 'object' ? element.scope : null
190
+ const ancestorScope = parent?.scope
191
+ const globalScope =
192
+ (element.context && typeof element.context.globalScope === 'object' && element.context.globalScope) || null
193
+ const baseScope = ancestorScope ||
194
+ (globalScope ? Object.create(globalScope) : null)
195
+ if (definition.scope !== undefined || inheritedFromExtends) {
196
+ const target = baseScope ? Object.create(baseScope) : {}
197
+ if (inheritedFromExtends) Object.assign(target, inheritedFromExtends)
198
+ if (definition.scope !== undefined) Object.assign(target, definition.scope)
199
+ element.scope = target
200
+ } else if (baseScope) {
201
+ element.scope = Object.create(baseScope)
164
202
  }
165
203
  if (definition.if !== undefined) element.if = definition.if
166
204
 
@@ -289,15 +327,24 @@ export function create(definition, parent, key, options = {}, attachOptions) {
289
327
  triggerLifecycle('RenderRouter', element, options)
290
328
 
291
329
  // Start onFrame loop if defined
330
+ // FT-FRAMEWORK-3: Previously this returned early when `!element.node.isConnected`,
331
+ // killing the loop. During an initial top-down mount, the first rAF often
332
+ // fires before the root has been attached to document.body — so deeply
333
+ // nested children's onFrame loops self-terminated and never restarted.
334
+ // Now we reschedule unconditionally and gate only the handler call on
335
+ // `isConnected`. The loop stops only when the element is disposed
336
+ // (`element.node === null`).
292
337
  if (typeof element.onFrame === 'function') {
293
338
  const frameHandler = element.onFrame
294
339
  const frameDsConfig = element.context?.designSystem
295
340
  const tick = () => {
296
- if (!element.node || !element.node.isConnected) return
297
- if (frameDsConfig) pushConfig(frameDsConfig)
298
- try { frameHandler(element, element.state, element.context) } catch (e) { console.error('[DOMQL] onFrame error:', e) }
299
- finally {
300
- if (frameDsConfig) popConfig()
341
+ if (!element.node) return
342
+ if (element.node.isConnected) {
343
+ if (frameDsConfig) pushConfig(frameDsConfig)
344
+ try { frameHandler(element, element.state, element.context) } catch (e) { console.error('[DOMQL] onFrame error:', e) }
345
+ finally {
346
+ if (frameDsConfig) popConfig()
347
+ }
301
348
  }
302
349
  requestAnimationFrame(tick)
303
350
  }
@@ -316,7 +363,21 @@ function createElementState(element, parent, definition) {
316
363
  // Dashboard defines `state: { useJson: true }` would lose its state because
317
364
  // the local definition has no `state` field, and we'd silently inherit
318
365
  // parent state instead.
319
- const stateDef = definition.state !== undefined
366
+ // Treat an empty plain-object `state: {}` declaration as "not provided"
367
+ // when this is the root element AND context.state has content. Without
368
+ // this, scaffolded `app.js` files that ship with `state: {}` boilerplate
369
+ // would silently override context.state (loaded from `state.js`) and
370
+ // leave the root state empty at runtime — see chatbot template where
371
+ // `s.root.navTabs` resolved undefined despite a populated state.js.
372
+ // Explicit non-empty objects + non-root elements bypass this rewrite.
373
+ const isRootCandidate = !parent
374
+ const isEmptyObjectState = definition.state &&
375
+ typeof definition.state === 'object' &&
376
+ !Array.isArray(definition.state) &&
377
+ Object.keys(definition.state).length === 0
378
+ const useContextStateFallback = isRootCandidate && isEmptyObjectState && element.context?.state
379
+
380
+ const stateDef = (definition.state !== undefined && !useContextStateFallback)
320
381
  ? definition.state
321
382
  : (element.state && !isStore(element.state) ? element.state : undefined)
322
383
 
@@ -444,9 +505,75 @@ function resolveStatePath(path, parent) {
444
505
 
445
506
  // --- Reactive Effects ---
446
507
 
508
+ function wrapCustomPropFunctions(element) {
509
+ const ref = element.__ref
510
+ const tag = element.tag || 'div'
511
+ const ds = element.context?.designSystem
512
+
513
+ // Two-pass: collect first, install second. Lets sibling refs like
514
+ // `a: fn`, `b: (el) => el.a + 1` work regardless of declaration order
515
+ // because every peer is already a value-producing getter once we begin
516
+ // evaluating any of them.
517
+ const targets = []
518
+ const ownProps = Object.keys(element)
519
+ for (let i = 0; i < ownProps.length; i++) {
520
+ const prop = ownProps[i]
521
+ if (typeof element[prop] !== 'function') continue
522
+ if (REGISTRY.has(prop)) continue
523
+ const firstChar = prop.charCodeAt(0)
524
+ if (firstChar >= 65 && firstChar <= 90) continue // child component
525
+ if (isEventHandler(prop)) continue
526
+ if (CSS_PROPS_REGISTRY[prop]) continue // handled by CSS effect loop
527
+ if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) continue
528
+ if (checkAttributeByTagName(tag, prop)) continue // handled by attr effect loop
529
+ targets.push({ prop, fn: element[prop] })
530
+ }
531
+
532
+ for (let i = 0; i < targets.length; i++) {
533
+ const { prop, fn } = targets[i]
534
+ ref.__exec[prop] = fn
535
+ Object.defineProperty(element, prop, {
536
+ get () {
537
+ // Re-run fn on each read. Signal dependency tracking still works:
538
+ // whatever effect is reading el[prop] subscribes to state signals
539
+ // touched inside fn, so it re-fires on state changes. We skip a
540
+ // memoizing layer (createComputed) so fn never sees a peer custom
541
+ // prop in its raw-function form during this pass's eager init.
542
+ if (ds) pushConfig(ds)
543
+ try {
544
+ return fn(element, element.state, element.context)
545
+ } catch (e) {
546
+ return undefined
547
+ } finally {
548
+ if (ds) popConfig()
549
+ }
550
+ },
551
+ set (val) {
552
+ delete ref.__exec[prop]
553
+ Object.defineProperty(element, prop, {
554
+ value: val, writable: true, configurable: true, enumerable: true
555
+ })
556
+ },
557
+ configurable: true,
558
+ enumerable: true
559
+ })
560
+ }
561
+ }
562
+
447
563
  function registerEffects(element, definition) {
448
564
  const ref = element.__ref
449
565
 
566
+ // Custom-prop function reactivity (FRAMEWORK-1).
567
+ // Replace user-defined function-valued props (lowercase, not framework-
568
+ // reserved, not CSS, not HTML attribute, not event/lifecycle) with a getter
569
+ // that calls the function on read. Reads via `el.X` — including
570
+ // `el.parent.X` from descendants — return the evaluated value, and signal
571
+ // dependency tracking propagates through dependent effects (`text:`,
572
+ // `width:`, `href:`, etc.). Without this wrap, dependent effects coerce the
573
+ // un-invoked function via `Function.prototype.toString` and ship the
574
+ // arrow-function source into the DOM (e.g. "(el, s) => 8 / 10").
575
+ wrapCustomPropFunctions(element)
576
+
450
577
  // Text effect
451
578
  if (typeof element.text === 'function') {
452
579
  const fn = element.text
@@ -590,6 +717,23 @@ function registerEffects(element, definition) {
590
717
  }, { depth: ref.path.length })
591
718
  ref.__effects.push(effect)
592
719
  ref.__exec.style = fn
720
+ } else if (element.style && typeof element.style === 'object') {
721
+ // Nested-object style: function values per-key get individual effects.
722
+ // Static keys go through applyStaticMixins → applyStyles at mount.
723
+ // Without this branch, `style: { color: (el, s) => s.theme }` runs
724
+ // once at mount and never re-evaluates, while flat top-level
725
+ // `color: fn` IS reactive — surprising parity gap (FA-L23).
726
+ for (const styleKey in element.style) {
727
+ const styleFn = element.style[styleKey]
728
+ if (typeof styleFn !== 'function') continue
729
+ const effect = createEffect(() => {
730
+ try {
731
+ const val = styleFn(element, element.state, element.context)
732
+ if (element.node) applyStyles(element.node, { [styleKey]: val })
733
+ } catch (e) { /* style fn may fail before state is populated */ }
734
+ }, { depth: ref.path.length })
735
+ ref.__effects.push(effect)
736
+ }
593
737
  }
594
738
 
595
739
  // Attribute effects for function-valued attrs (check element.attr which includes extends)
@@ -617,6 +761,84 @@ function registerEffects(element, definition) {
617
761
  }
618
762
  }
619
763
 
764
+ // Reactive nested aria.X — `aria: { label: (el, s) => s.title }` re-runs
765
+ // on every state change. Static aria.X values are flattened to top-level
766
+ // attrs by `filterAttributesByTagName` (attrs-in-props/index.js), so we
767
+ // only wire effects for function values here.
768
+ if (element.aria && typeof element.aria === 'object') {
769
+ for (const ariaKey in element.aria) {
770
+ const ariaFn = element.aria[ariaKey]
771
+ if (typeof ariaFn !== 'function') continue
772
+ const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
773
+ const effect = createEffect(() => {
774
+ try {
775
+ const val = ariaFn(element, element.state, element.context)
776
+ if (!element.node?.setAttribute) return
777
+ if (val == null || val === false) {
778
+ element.node.removeAttribute(attrName)
779
+ } else if (typeof val !== 'function' && typeof val !== 'object') {
780
+ element.node.setAttribute(attrName, val === true ? '' : val)
781
+ }
782
+ } catch (e) { /* aria fn may fail before state is populated */ }
783
+ }, { depth: ref.path.length })
784
+ ref.__effects.push(effect)
785
+ }
786
+ }
787
+
788
+ // Reactive nested data.X — same shape as aria, kebab-cases the key.
789
+ if (element.data && typeof element.data === 'object') {
790
+ for (const dataKey in element.data) {
791
+ const dataFn = element.data[dataKey]
792
+ if (typeof dataFn !== 'function') continue
793
+ const kebab = dataKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
794
+ const attrName = 'data-' + kebab
795
+ const effect = createEffect(() => {
796
+ try {
797
+ const val = dataFn(element, element.state, element.context)
798
+ if (!element.node?.setAttribute) return
799
+ if (val == null || val === false) {
800
+ element.node.removeAttribute(attrName)
801
+ } else if (typeof val !== 'function' && typeof val !== 'object') {
802
+ element.node.setAttribute(attrName, val === true ? '' : val)
803
+ }
804
+ } catch (e) { /* data fn may fail before state is populated */ }
805
+ }, { depth: ref.path.length })
806
+ ref.__effects.push(effect)
807
+ }
808
+ }
809
+
810
+ // Reactive classlist / class — `class: { active: (el, s) => s.isOpen }`
811
+ // toggles on state change. Static keys are applied at mount by
812
+ // applyStaticMixins; only function-valued keys need effects here.
813
+ const classObj = (element.classlist && typeof element.classlist === 'object' && !Array.isArray(element.classlist))
814
+ ? element.classlist
815
+ : (element.class && typeof element.class === 'object' && !Array.isArray(element.class))
816
+ ? element.class
817
+ : null
818
+ if (classObj) {
819
+ for (const className in classObj) {
820
+ const classFn = classObj[className]
821
+ if (typeof classFn !== 'function') continue
822
+ const effect = createEffect(() => {
823
+ try {
824
+ const val = classFn(element, element.state, element.context)
825
+ if (!element.node?.classList) return
826
+ // Mirror the static-path logic: `val: 'custom-name'` → toggle that
827
+ // string class on; falsy → toggle base key off; truthy non-string →
828
+ // toggle base key on.
829
+ if (typeof val === 'string') {
830
+ element.node.classList.add(val)
831
+ } else if (val) {
832
+ element.node.classList.add(className)
833
+ } else {
834
+ element.node.classList.remove(className)
835
+ }
836
+ } catch (e) { /* class fn may fail before state is populated */ }
837
+ }, { depth: ref.path.length })
838
+ ref.__effects.push(effect)
839
+ }
840
+ }
841
+
620
842
  // If condition effect
621
843
  if (typeof definition.if === 'function') {
622
844
  const fn = definition.if
@@ -785,10 +1007,17 @@ function applyStaticMixins(element) {
785
1007
  }
786
1008
  }
787
1009
 
788
- // Data attributes
1010
+ // Data attributes — same guard shape as the attr loop above. Function-
1011
+ // valued data.X is handled by the reactive-data effect in registerEffects;
1012
+ // writing the function reference into `dataset` here would render as a
1013
+ // stringified function literal in the DOM until something else overwrote
1014
+ // it. Skip null/false/object values too.
789
1015
  if (element.data && typeof element.data === 'object') {
790
1016
  for (const key in element.data) {
791
- if (node.dataset) node.dataset[key] = element.data[key]
1017
+ const val = element.data[key]
1018
+ if (typeof val === 'function' || typeof val === 'object') continue
1019
+ if (val == null || val === false) continue
1020
+ if (node.dataset) node.dataset[key] = val === true ? '' : val
792
1021
  }
793
1022
  }
794
1023
 
@@ -826,12 +1055,22 @@ function applyStyles(node, styles) {
826
1055
 
827
1056
  try {
828
1057
  const str = String(val)
829
- if (str.includes('!important')) {
830
- const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
831
- node.style.setProperty(cssProp, str.replace(/\s*!important\s*$/, ''), 'important')
832
- } else {
833
- node.style[prop] = val
834
- }
1058
+ // Reactive writes go through `setProperty` as plain inline (no
1059
+ // priority). Inline already beats class-rule selectors in the cascade
1060
+ // — the smbls atomic CSS engine never emits `!important`, so a plain
1061
+ // inline value wins on its own. The previous `'important'` priority
1062
+ // was a cargo-culted band-aid for an apparent stale-getComputedStyle
1063
+ // observation; in practice it caused CSS transitions on the same
1064
+ // element to leave stuck `Animation` objects (currentTime:0,
1065
+ // fill:'backwards') because Chrome's transition state machine
1066
+ // mishandles a mid-transition switch to/from `!important`.
1067
+ // If a value carries an explicit `!important` suffix from upstream
1068
+ // (rare — design tokens etc.), honor it.
1069
+ const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1070
+ const trimmed = str.trim()
1071
+ const isImportant = /\s*!important\s*$/.test(trimmed)
1072
+ const cleanVal = trimmed.replace(/\s*!important\s*$/, '')
1073
+ node.style.setProperty(cssProp, cleanVal, isImportant ? 'important' : '')
835
1074
  } catch (e) {
836
1075
  // Invalid style value
837
1076
  }
@@ -1049,8 +1288,13 @@ function applyCssInProps(element) {
1049
1288
  cssStyles[prop] = val
1050
1289
  hasStyles = true
1051
1290
  }
1052
- } else if (prop.charCodeAt(0) === 45 && prop.charCodeAt(1) === 45) {
1053
- // CSS custom properties (--var-name): apply as inline style
1291
+ } else if (prop.charCodeAt(0) === 45) {
1292
+ // FT-FRAMEWORK-4: any dash-prefixed CSS key — both custom properties
1293
+ // (`--var-name`) and vendor-prefixed properties
1294
+ // (`-webkit-text-stroke`, `-moz-osx-font-smoothing`, …) — pass
1295
+ // straight through the atomic CSS engine. Previously only `--`
1296
+ // matched; `-webkit-text-stroke: 1px darkgrey` at the top level was
1297
+ // silently dropped, forcing consumers to wrap it in `style: { ... }`.
1054
1298
  const val = element[prop]
1055
1299
  if (val != null && typeof val !== 'object') {
1056
1300
  cssStyles[prop] = val
@@ -1208,6 +1452,22 @@ function applyAttrsInProps(element) {
1208
1452
  const { node } = element
1209
1453
  if (!node || !node.setAttribute) return
1210
1454
 
1455
+ // Apply STATIC nested aria.X / data.X values at mount. Function values
1456
+ // are owned by the reactive aria/data effects in registerEffects — those
1457
+ // re-run on state changes. Without this loop, aria/data static values
1458
+ // declared as nested objects (`aria: { label: 'Search' }`) silently
1459
+ // dropped — `applyStaticMixins` already promoted top-level data.X to
1460
+ // dataset and the parallel aria branch had no equivalent.
1461
+ if (element.aria && typeof element.aria === 'object') {
1462
+ for (const ariaKey in element.aria) {
1463
+ const v = element.aria[ariaKey]
1464
+ if (typeof v === 'function' || typeof v === 'object') continue
1465
+ if (v == null || v === false) continue
1466
+ const attrName = 'aria-' + ariaKey.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1467
+ node.setAttribute(attrName, v === true ? '' : v)
1468
+ }
1469
+ }
1470
+
1211
1471
  // Apply attribute transforms (src, href, poster, etc. with file/template resolution)
1212
1472
  const transformed = applyAttrTransforms(element)
1213
1473
  for (const attr in transformed) {
@@ -1264,8 +1524,10 @@ function registerEvents(element, options) {
1264
1524
 
1265
1525
  let handler = element[key]
1266
1526
 
1267
- // String shortcut: onClick: 'fnName' → el.call('fnName', ev)
1268
- if (typeof handler === 'string') {
1527
+ // String shortcut: onClick: 'fnName' → el.call('fnName', ev).
1528
+ // Only valid JS identifiers — arbitrary strings like 'not a function' fall
1529
+ // through to the typeof-function check below and are skipped.
1530
+ if (typeof handler === 'string' && /^[A-Za-z_$][\w$]*$/.test(handler)) {
1269
1531
  const fnName = handler
1270
1532
  handler = function stringHandlerShortcut (ev) {
1271
1533
  return element.call(fnName, ev)
package/src/extends.js CHANGED
@@ -5,16 +5,33 @@
5
5
 
6
6
  const extendsCache = new WeakMap()
7
7
 
8
+ // FT-FRAMEWORK-EXTEND-CUSTOM: collect entries from BOTH `extends:`
9
+ // (plural) and `extend:` (singular). Previously the resolver did
10
+ // `definition.extends || definition.extend`, silently dropping one when
11
+ // the other was set. childsafetynet's blog-detail pages used both
12
+ // (`extends: 'Page'` for route-mode + `extend: 'BlogArticle'` for the
13
+ // article shell) and only Page resolved — BlogArticle's child keys
14
+ // never reached the render tree.
15
+ function collectExtendsList (definition) {
16
+ const list = []
17
+ if (definition.extends) {
18
+ if (Array.isArray(definition.extends)) list.push(...definition.extends)
19
+ else list.push(definition.extends)
20
+ }
21
+ if (definition.extend) {
22
+ if (Array.isArray(definition.extend)) list.push(...definition.extend)
23
+ else list.push(definition.extend)
24
+ }
25
+ return list
26
+ }
27
+
8
28
  export function applyExtends(element, definition, options) {
9
- let ext = definition.extends || definition.extend
10
- if (!ext) return
29
+ const ext = collectExtendsList(definition)
30
+ if (!ext.length) return
11
31
 
12
32
  const context = element.context || options?.context || {}
13
33
  const components = context.components || {}
14
34
 
15
- // Normalize to array
16
- if (!Array.isArray(ext)) ext = [ext]
17
-
18
35
  // Resolve extends chain
19
36
  for (let i = 0; i < ext.length; i++) {
20
37
  const base = resolveExtend(ext[i], components)
@@ -48,13 +65,14 @@ function mergeBase(element, base, options, visited = new Set()) {
48
65
  if (visited.has(base)) return
49
66
  visited.add(base)
50
67
 
51
- // Recursively resolve base's own extends first (support both 'extends' and 'extend')
52
- const baseExtends = base.extends || base.extend
53
- if (baseExtends) {
68
+ // Recursively resolve base's own extends first — combining both
69
+ // `extends:` (plural) and `extend:` (singular). Mirrors the entry-point
70
+ // collectExtendsList logic so chains like Page → BlogArticle → Flex
71
+ // resolve correctly when intermediate components mix the two forms.
72
+ const baseExt = collectExtendsList(base)
73
+ if (baseExt.length) {
54
74
  const context = element.context || options?.context || {}
55
75
  const components = context.components || {}
56
- let baseExt = baseExtends
57
- if (!Array.isArray(baseExt)) baseExt = [baseExt]
58
76
  for (let i = 0; i < baseExt.length; i++) {
59
77
  const ancestor = resolveExtend(baseExt[i], components)
60
78
  if (ancestor) mergeBase(element, ancestor, options, visited)
package/src/methods.js CHANGED
@@ -152,10 +152,20 @@ const elementProto = {
152
152
  parse(exclude) {
153
153
  const result = {}
154
154
  const excludeSet = exclude ? new Set(exclude) : null
155
+ const exec = this.__ref?.__exec
155
156
  for (const key in this) {
156
157
  if (key === 'parent' || key === 'node' || key === '__ref') continue
157
- if (typeof this[key] === 'function') continue
158
158
  if (excludeSet?.has(key)) continue
159
+ // Reactive props (text/html/value/style/attr/css/custom-prop fns) are
160
+ // wrapped at create time; the live value is the evaluated result, but
161
+ // ref.__exec[key] holds the original function (or template). Restore it
162
+ // so reset() / parseDeep() reproduce the reactive definition rather
163
+ // than baking in a snapshot value.
164
+ if (exec && exec[key] !== undefined) {
165
+ result[key] = exec[key]
166
+ continue
167
+ }
168
+ if (typeof this[key] === 'function') continue
159
169
  result[key] = this[key]
160
170
  }
161
171
  return result
@@ -195,7 +205,11 @@ const elementProto = {
195
205
  setNodeStyles(styles) {
196
206
  if (!this.node?.style) return
197
207
  for (const prop in styles) {
198
- this.node.style[prop] = styles[prop]
208
+ const val = styles[prop]
209
+ if (val == null) continue
210
+ const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
211
+ const str = String(val).replace(/\s*!important\s*$/, '')
212
+ this.node.style.setProperty(cssProp, str, 'important')
199
213
  }
200
214
  },
201
215
 
package/src/update.js CHANGED
@@ -47,11 +47,19 @@ export function update(params = {}, opts = {}) {
47
47
  }
48
48
  }
49
49
 
50
- // Update style
50
+ // Update style — mirrors applyStyles in create.js: write via setProperty
51
+ // with `important` priority so cascade resolves to the inline value
52
+ // even when the element carries atomic CSS classes for sibling static
53
+ // props. Plain `node.style[prop] = val` writes don't invalidate the
54
+ // cached cascade when the first-paint resolved to a different value.
51
55
  if (params.style && element.node?.style) {
52
56
  if (!ref.__exec.style) {
53
57
  for (const prop in params.style) {
54
- element.node.style[prop] = params.style[prop]
58
+ const val = params.style[prop]
59
+ if (val == null) continue
60
+ const cssProp = prop.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
61
+ const str = String(val).replace(/\s*!important\s*$/, '')
62
+ element.node.style.setProperty(cssProp, str, 'important')
55
63
  }
56
64
  }
57
65
  if (!element.style) element.style = {}