@skirbi/sugar 0.1.11 → 0.1.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.
package/Changes CHANGED
@@ -1,5 +1,108 @@
1
1
  Revision history for @skirbi/sugar
2
2
 
3
+ 0.1.13 2026-07-01 19:22:49Z
4
+
5
+ * Whoopsy. Succa ta cu un c :) And also export the damn thing. haha.
6
+
7
+ 0.1.12 2026-07-01 16:53:26Z
8
+
9
+ * Due to a change in an upstream project the morphing behaviour came under
10
+ scrutiny again (0.1.10/11). This caused a regroup moment on my part where
11
+ all the hooks and knobs of withConnectedSugar were re-examened and I
12
+ decided to rewrite them into a consistent new state that leverages over the
13
+ past iterations. I probably should have done it a bit earlier if honest.
14
+
15
+ We now introduce: withConnectSuccu.
16
+
17
+ With withConnectSuccu, a second-generation connected-lifecycle mixin,
18
+ alongside the existing withConnectedSugar. It is built and tested in
19
+ parallel so existing components can migrate one at a time rather than all
20
+ at once.
21
+
22
+ This documents the full API a component gets when it opts into morph
23
+ support via withConnectSuccu.
24
+
25
+ ## Required
26
+
27
+ - `static renderGuardSelector`: CSS selector proving "I have already
28
+ rendered." Unchanged from withConnectedSugar. Required for any
29
+ render-guarding, attribute reactivity, or morph reactivity to work
30
+ at all; a component with no renderGuardSelector renders once and
31
+ never reacts to anything.
32
+
33
+ - `connectedCallbackSugar()`: the component's render implementation.
34
+ Called on first connect, and again on any morph that matches
35
+ `morphTriggerSelector` (see below) while renderGuardSelector is not
36
+ yet satisfied.
37
+
38
+ As of withConnectSuccu, every call to connectedCallbackSugar,
39
+ initial or morph-triggered, is automatically wrapped in an
40
+ observer pause. A component's own render mutates its own children;
41
+ previously that mutation could be seen by the component's own
42
+ MutationObserver and misread as an external morph, silently
43
+ triggering morphedCallbackSugar as a side effect of the component
44
+ simply rendering itself. This is no longer something component
45
+ authors need to think about or guard against manually.
46
+
47
+ ## Attribute reactivity
48
+
49
+ - `static attributeDefs[attrName].hook`: unchanged. String selector
50
+ or function, fires on any observed attribute change once rendered,
51
+ for simple "put this value somewhere in my DOM" cases.
52
+
53
+ - `updatedAttributeSugar(name, oldVal, newVal)`: new. Escape hatch
54
+ for attribute changes that need real logic, not just a textContent
55
+ swap: refetching data, rebuilding a sub-structure, branching per
56
+ attribute. One method, all reactive attributes; branch on `name`
57
+ if different attributes need different handling.
58
+
59
+ Named after Livewire's own `updatedXyz` convention, deliberately
60
+ this is the method component authors reach for instead of doing
61
+ nothing, which was the actual production bug that motivated this
62
+ whole change (semtic-table never reacted to its own `data`
63
+ attribute changing after first render).
64
+
65
+ Replaces attributeChangedCallbackSugar, which will be removed outright in
66
+ a future release. No existing (skirbi) component consumed it, so no
67
+ migration path was needed.
68
+
69
+ ## Children / shape reactivity
70
+
71
+ - `static morphTriggerSelector`: CSS selector matched against the
72
+ component's own children. If a morph adds matching content while
73
+ renderGuardSelector is not yet satisfied, connectedCallbackSugar
74
+ runs again.
75
+
76
+ This selector MUST match only the component's authored/pre-render
77
+ shape, never its own rendered output. A wildcard like `:scope > *`
78
+ will also match the component's own rendered children once
79
+ renderGuardSelector is satisfied, which is exactly what produced
80
+ the doubled `<footer>` bug found via a production Livewire trace on
81
+ semtic-actions. Prefer a specific tag or attribute selector that
82
+ stops matching once the component has rendered.
83
+
84
+ - `morphedCallbackSugar()`: unchanged. Called when the component is
85
+ already rendered and the observer sees a mutation that isn't a
86
+ fresh morphTriggerSelector match, i.e. existing rendered structure
87
+ was morphed in place rather than replaced. Now genuinely only fires
88
+ for real external morphs; see the self-render protection note
89
+ above.
90
+
91
+ ## Not declarative, and not opt-in
92
+
93
+ - Self-render protection (the observer pause around connectedCallbackSugar)
94
+ is unconditional for every component using withConnectSuccu. It was
95
+ considered as a declarative flag, but there's no real case where a
96
+ component wants its own render visible to its own observer, so it isn't
97
+ part of the configurable surface at all.
98
+
99
+ withConnectSuccu remains a mixin, not a base class. Composability
100
+ with other traits was weighed against making it the HTMLElementSugar
101
+ default; for now @skirbi/sugar stays a general-purpose layer that
102
+ doesn't mandate morph-awareness for consumers outside the @skirbi
103
+ ecosystem, even though within @skirbi (semtic, dibuho) it is the
104
+ de facto convention almost everywhere.
105
+
3
106
  0.1.11 2026-07-01 04:10:34Z
4
107
 
5
108
  * 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.13";
6
6
 
7
7
  export { HTMLElementSugar } from './htmlelement.mjs';
8
8
  export { HTMLElementSugarInput } from './htmlelement-input.mjs';
@@ -11,3 +11,4 @@ export { parseBoolean } from '@opndev/html';
11
11
  export { registerDevAlias, applyDevAliases } from './aliases.mjs';
12
12
  export { registerAliases } from './aliases-register.mjs';
13
13
  export { withConnectedSugar } from './with-connected-sugar.mjs';
14
+ export { withConnectedSucu } from './with-connected-sucu.mjs';
@@ -0,0 +1,196 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+
5
+ /**
6
+ * withConnectSucu
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 sucuState = new WeakMap();
22
+
23
+ export const withConnectSucu = (Base) =>
24
+ class extends Base {
25
+ static morphTriggerSelector = '';
26
+
27
+ connectedCallback() {
28
+ if (!sucuState.has(this)) {
29
+ sucuState.set(this, {
30
+ rendered: false,
31
+ firstUpdatedCalled: false,
32
+ });
33
+ }
34
+ const state = sucuState.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 || !sucuState.has(parent)) {
79
+ cb();
80
+ return;
81
+ }
82
+ const parentState = sucuState.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._sucuMo) 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._sucuMo = new MutationObserver((mutations) => {
133
+
134
+ if (this.hasAttribute('morph-debug')) {
135
+ console.group(`[sucu-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._sucuMo.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._sucuMo?.disconnect();
182
+ try {
183
+ fn();
184
+ } finally {
185
+ if (this._morphObserveOptions) {
186
+ this._sucuMo?.observe(this, this._morphObserveOptions);
187
+ }
188
+ }
189
+ }
190
+
191
+ disconnectedCallback() {
192
+ this._sucuMo?.disconnect();
193
+ this._sucuMo = 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.13"
66
66
  }