@skirbi/sugar 0.1.11 → 0.1.12

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/Changes CHANGED
@@ -1,5 +1,104 @@
1
1
  Revision history for @skirbi/sugar
2
2
 
3
+ 0.1.12 2026-07-01 16:53:26Z
4
+
5
+ * Due to a change in an upstream project the morphing behaviour came under
6
+ scrutiny again (0.1.10/11). This caused a regroup moment on my part where
7
+ all the hooks and knobs of withConnectedSugar were re-examened and I
8
+ decided to rewrite them into a consistent new state that leverages over the
9
+ past iterations. I probably should have done it a bit earlier if honest.
10
+
11
+ We now introduce: withConnectSuccu.
12
+
13
+ With withConnectSuccu, a second-generation connected-lifecycle mixin,
14
+ alongside the existing withConnectedSugar. It is built and tested in
15
+ parallel so existing components can migrate one at a time rather than all
16
+ at once.
17
+
18
+ This documents the full API a component gets when it opts into morph
19
+ support via withConnectSuccu.
20
+
21
+ ## Required
22
+
23
+ - `static renderGuardSelector`: CSS selector proving "I have already
24
+ rendered." Unchanged from withConnectedSugar. Required for any
25
+ render-guarding, attribute reactivity, or morph reactivity to work
26
+ at all; a component with no renderGuardSelector renders once and
27
+ never reacts to anything.
28
+
29
+ - `connectedCallbackSugar()`: the component's render implementation.
30
+ Called on first connect, and again on any morph that matches
31
+ `morphTriggerSelector` (see below) while renderGuardSelector is not
32
+ yet satisfied.
33
+
34
+ As of withConnectSuccu, every call to connectedCallbackSugar,
35
+ initial or morph-triggered, is automatically wrapped in an
36
+ observer pause. A component's own render mutates its own children;
37
+ previously that mutation could be seen by the component's own
38
+ MutationObserver and misread as an external morph, silently
39
+ triggering morphedCallbackSugar as a side effect of the component
40
+ simply rendering itself. This is no longer something component
41
+ authors need to think about or guard against manually.
42
+
43
+ ## Attribute reactivity
44
+
45
+ - `static attributeDefs[attrName].hook`: unchanged. String selector
46
+ or function, fires on any observed attribute change once rendered,
47
+ for simple "put this value somewhere in my DOM" cases.
48
+
49
+ - `updatedAttributeSugar(name, oldVal, newVal)`: new. Escape hatch
50
+ for attribute changes that need real logic, not just a textContent
51
+ swap: refetching data, rebuilding a sub-structure, branching per
52
+ attribute. One method, all reactive attributes; branch on `name`
53
+ if different attributes need different handling.
54
+
55
+ Named after Livewire's own `updatedXyz` convention, deliberately
56
+ this is the method component authors reach for instead of doing
57
+ nothing, which was the actual production bug that motivated this
58
+ whole change (semtic-table never reacted to its own `data`
59
+ attribute changing after first render).
60
+
61
+ Replaces attributeChangedCallbackSugar, which will be removed outright in
62
+ a future release. No existing (skirbi) component consumed it, so no
63
+ migration path was needed.
64
+
65
+ ## Children / shape reactivity
66
+
67
+ - `static morphTriggerSelector`: CSS selector matched against the
68
+ component's own children. If a morph adds matching content while
69
+ renderGuardSelector is not yet satisfied, connectedCallbackSugar
70
+ runs again.
71
+
72
+ This selector MUST match only the component's authored/pre-render
73
+ shape, never its own rendered output. A wildcard like `:scope > *`
74
+ will also match the component's own rendered children once
75
+ renderGuardSelector is satisfied, which is exactly what produced
76
+ the doubled `<footer>` bug found via a production Livewire trace on
77
+ semtic-actions. Prefer a specific tag or attribute selector that
78
+ stops matching once the component has rendered.
79
+
80
+ - `morphedCallbackSugar()`: unchanged. Called when the component is
81
+ already rendered and the observer sees a mutation that isn't a
82
+ fresh morphTriggerSelector match, i.e. existing rendered structure
83
+ was morphed in place rather than replaced. Now genuinely only fires
84
+ for real external morphs; see the self-render protection note
85
+ above.
86
+
87
+ ## Not declarative, and not opt-in
88
+
89
+ - Self-render protection (the observer pause around connectedCallbackSugar)
90
+ is unconditional for every component using withConnectSuccu. It was
91
+ considered as a declarative flag, but there's no real case where a
92
+ component wants its own render visible to its own observer, so it isn't
93
+ part of the configurable surface at all.
94
+
95
+ withConnectSuccu remains a mixin, not a base class. Composability
96
+ with other traits was weighed against making it the HTMLElementSugar
97
+ default; for now @skirbi/sugar stays a general-purpose layer that
98
+ doesn't mandate morph-awareness for consumers outside the @skirbi
99
+ ecosystem, even though within @skirbi (semtic, dibuho) it is the
100
+ de facto convention almost everywhere.
101
+
3
102
  0.1.11 2026-07-01 04:10:34Z
4
103
 
5
104
  * Debugging session start. There is a weird case of it works via local file
package/lib/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
4
 
5
- const VERSION = "0.1.11";
5
+ const VERSION = "0.1.12";
6
6
 
7
7
  export { HTMLElementSugar } from './htmlelement.mjs';
8
8
  export { HTMLElementSugarInput } from './htmlelement-input.mjs';
@@ -0,0 +1,196 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+
5
+ /**
6
+ * withConnectSuccu
7
+ *
8
+ * Second-generation connected-lifecycle mixin. Built and tested alongside
9
+ * withConnectedSugar so existing components can migrate one at a time.
10
+ *
11
+ * Changes vs withConnectedSugar:
12
+ * - Self-render protection is unconditional. Every call to
13
+ * connectedCallbackSugar (initial render or morph-triggered re-render)
14
+ * is automatically wrapped in an observer pause, so a component's own
15
+ * render can never self-trigger morphedCallbackSugar. Component authors
16
+ * no longer call withMorphObserverPaused themselves for this.
17
+ * - attributeChangedCallbackSugar is removed. Use updatedAttributeSugar
18
+ * (wired up in htmlelement.mjs's attributeChangedCallback) instead.
19
+ */
20
+
21
+ const succuState = new WeakMap();
22
+
23
+ export const withConnectSuccu = (Base) =>
24
+ class extends Base {
25
+ static morphTriggerSelector = '';
26
+
27
+ connectedCallback() {
28
+ if (!succuState.has(this)) {
29
+ succuState.set(this, {
30
+ rendered: false,
31
+ firstUpdatedCalled: false,
32
+ });
33
+ }
34
+ const state = succuState.get(this);
35
+
36
+ this._syncObservedAttributesToConfig();
37
+ this._armMorphObserver();
38
+
39
+ if (this.connectedCallbackSugarOnce) {
40
+ this.connectedCallbackSugarOnce();
41
+ }
42
+
43
+ if (this._shouldRenderOnConnect()) {
44
+ const wcParent = this._findWcParent();
45
+
46
+ const render = () => {
47
+ this._renderProtected();
48
+ state.rendered = true;
49
+
50
+ const event = new CustomEvent('wc:rendered', { bubbles: false });
51
+ this.dispatchEvent(event);
52
+
53
+ if (!state.firstUpdatedCalled) {
54
+ Promise.resolve().then(() => {
55
+ state.firstUpdatedCalled = true;
56
+ this.firstUpdatedSugar?.();
57
+ });
58
+ }
59
+ };
60
+
61
+ this.renderIfParent(wcParent, render);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Runs connectedCallbackSugar with the observer paused. Shared by both
67
+ * the initial-connect render and the morph-triggered re-render, so a
68
+ * component's own render mutation is never mistaken for an external
69
+ * morph by its own MutationObserver.
70
+ */
71
+ _renderProtected() {
72
+ this.withMorphObserverPaused(() => {
73
+ this.connectedCallbackSugar();
74
+ });
75
+ }
76
+
77
+ renderIfParent(parent = null, cb) {
78
+ if (!parent || !succuState.has(parent)) {
79
+ cb();
80
+ return;
81
+ }
82
+ const parentState = succuState.get(parent);
83
+ if (parentState.rendered) {
84
+ cb();
85
+ return;
86
+ }
87
+
88
+ const timeout = setTimeout(cb, 1000);
89
+ parent.addEventListener('wc:rendered', () => {
90
+ clearTimeout(timeout);
91
+ cb();
92
+ }, { once: true });
93
+ }
94
+
95
+ _findWcParent() {
96
+ let el = this.parentElement;
97
+ while (el) {
98
+ if (el.tagName?.includes('-')) {
99
+ if (el._internals || el._sugarRendered !== undefined) {
100
+ return el;
101
+ }
102
+ return null;
103
+ }
104
+ el = el.parentElement;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ _syncObservedAttributesToConfig() {
110
+ for (const attr of this.constructor.observedAttributes ?? []) {
111
+ const val = this.getAttribute(attr);
112
+ if (val !== null) this._applyAttribute(attr, val);
113
+ }
114
+ }
115
+
116
+ _armMorphObserver() {
117
+ if (this._succuMo) return;
118
+ if (this.hasAttribute('morph-ignore')) return;
119
+
120
+ const hasExplicit = this.hasAttribute('morph-child') ||
121
+ this.hasAttribute('morph-attribute') ||
122
+ this.hasAttribute('morph-subtree');
123
+
124
+ this._morphObserveOptions = hasExplicit ? {
125
+ childList: this.hasAttribute('morph-child'),
126
+ attributes: this.hasAttribute('morph-attribute'),
127
+ subtree: this.hasAttribute('morph-subtree'),
128
+ } : {
129
+ childList: true,
130
+ };
131
+
132
+ this._succuMo = new MutationObserver((mutations) => {
133
+
134
+ if (this.hasAttribute('morph-debug')) {
135
+ console.group(`[succu-morph] ${this.tagName}${this.id ? '#' + this.id : ''}${this.getAttribute('label') ? `[label="${this.getAttribute('label')}"]` : ''}${this.getAttribute('title') ? `[title="${this.getAttribute('title')}"]` : ''}`);
136
+ console.log('parent:', this.parentElement?.tagName);
137
+ console.log('mutations:', mutations.map(m => ({
138
+ type: m.type,
139
+ added: Array.from(m.addedNodes).map(n => n.nodeName),
140
+ removed: Array.from(m.removedNodes).map(n => n.nodeName)
141
+ })));
142
+ console.log('hasRenderedShape:', this.hasRenderedShape());
143
+ console.log('shouldRenderOnMorph:', this._shouldRenderOnMorph());
144
+ setTimeout(() => console.log('innerHTML after:', this.innerHTML), 0);
145
+ console.groupEnd();
146
+ }
147
+
148
+ queueMicrotask(() => {
149
+ if (this._shouldRenderOnMorph()) {
150
+ this._renderProtected();
151
+ return;
152
+ }
153
+ if (this.hasRenderedShape() && this.morphedCallbackSugar) {
154
+ this.morphedCallbackSugar();
155
+ return;
156
+ }
157
+ });
158
+
159
+ });
160
+
161
+ this._succuMo.observe(this, this._morphObserveOptions);
162
+ }
163
+
164
+ _shouldRenderOnMorph() {
165
+ return this._hasMorphTrigger() && !this.hasRenderedShape();
166
+ }
167
+
168
+ _hasMorphTrigger() {
169
+ const sel = this.constructor.morphTriggerSelector;
170
+ return !!(sel && this.querySelector(sel));
171
+ }
172
+
173
+ _shouldRenderOnConnect() {
174
+ const guard = this.constructor.renderGuardSelector;
175
+ if (guard) return !this.hasRenderedShape();
176
+
177
+ return true;
178
+ }
179
+
180
+ withMorphObserverPaused(fn) {
181
+ this._succuMo?.disconnect();
182
+ try {
183
+ fn();
184
+ } finally {
185
+ if (this._morphObserveOptions) {
186
+ this._succuMo?.observe(this, this._morphObserveOptions);
187
+ }
188
+ }
189
+ }
190
+
191
+ disconnectedCallback() {
192
+ this._succuMo?.disconnect();
193
+ this._succuMo = null;
194
+ }
195
+
196
+ };
package/package.json CHANGED
@@ -62,5 +62,5 @@
62
62
  "./aliases-register"
63
63
  ],
64
64
  "type": "module",
65
- "version": "0.1.11"
65
+ "version": "0.1.12"
66
66
  }