@skirbi/sugar 0.1.10 → 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 +105 -0
- package/lib/index.mjs +1 -1
- package/lib/livewire.mjs +50 -57
- package/lib/with-connected-succu.mjs +196 -0
- package/package.json +1 -1
package/Changes
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
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
|
+
|
|
102
|
+
0.1.11 2026-07-01 04:10:34Z
|
|
103
|
+
|
|
104
|
+
* Debugging session start. There is a weird case of it works via local file
|
|
105
|
+
and not via npm. So I can't really rely on the local file mounts. Ship the
|
|
106
|
+
debug.
|
|
107
|
+
|
|
3
108
|
0.1.10 2026-07-01 01:54:46Z
|
|
4
109
|
|
|
5
110
|
* Add tests to semtic-select
|
package/lib/index.mjs
CHANGED
package/lib/livewire.mjs
CHANGED
|
@@ -15,30 +15,28 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Only import this when using Sugar components inside a Livewire application.
|
|
17
17
|
*/
|
|
18
|
+
console.log('[sugar-livewire] eval', performance.now())
|
|
18
19
|
|
|
19
|
-
/*
|
|
20
20
|
console.log('[sugar-livewire] module loaded', {
|
|
21
21
|
hasLivewire: !!window.Livewire,
|
|
22
22
|
hasHook: !!window.Livewire?.hook,
|
|
23
23
|
});
|
|
24
|
-
*/
|
|
25
24
|
|
|
26
25
|
let registered = false;
|
|
27
26
|
|
|
28
|
-
|
|
29
27
|
/**
|
|
30
28
|
* Registers the Sugar Livewire hydration hook.
|
|
31
29
|
*
|
|
32
30
|
* @returns {void}
|
|
33
31
|
*/
|
|
32
|
+
|
|
34
33
|
function registerSugarLivewire() {
|
|
35
|
-
/*
|
|
36
34
|
console.log('[sugar-livewire] register called', {
|
|
37
35
|
registered,
|
|
38
36
|
hasLivewire: !!window.Livewire,
|
|
39
37
|
hasHook: !!window.Livewire?.hook,
|
|
38
|
+
perf: performance.now(),
|
|
40
39
|
});
|
|
41
|
-
*/
|
|
42
40
|
|
|
43
41
|
if (registered) return;
|
|
44
42
|
if (!window.Livewire?.hook) return;
|
|
@@ -47,74 +45,69 @@ function registerSugarLivewire() {
|
|
|
47
45
|
|
|
48
46
|
window.Livewire.hook('component.init', ({ component }) => {
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
console.log('[sugar-livewire] component.init hit', component.id);
|
|
51
49
|
|
|
52
50
|
const original = component.processEffects.bind(component);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
staging.remove();
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
51
|
+
component.processEffects = function processSugarEffects(effects) {
|
|
52
|
+
|
|
53
|
+
console.log('[sugar-livewire] definitions', {
|
|
54
|
+
dibuho: !!customElements.get('dibuho-container-panel'),
|
|
55
|
+
panel: !!customElements.get('semtic-panel'),
|
|
56
|
+
header: !!customElements.get('semtic-header'),
|
|
57
|
+
perf: performance.now(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log('[sugar-livewire] processEffects hit', {
|
|
61
|
+
hasHtml: !!effects.html,
|
|
62
|
+
sugarHydrated: !!effects._sugarHydrated,
|
|
63
|
+
perf: performance.now(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!effects.html) return original(effects);
|
|
67
|
+
if (effects._sugarHydrated) return original(effects);
|
|
68
|
+
|
|
69
|
+
const before = effects.html;
|
|
70
|
+
const staging = document.createElement('div');
|
|
71
|
+
staging.style.display = 'none';
|
|
72
|
+
document.body.appendChild(staging);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
staging.innerHTML = before;
|
|
76
|
+
const after = staging.innerHTML;
|
|
77
|
+
effects.html = after;
|
|
78
|
+
effects._sugarHydrated = true;
|
|
79
|
+
|
|
80
|
+
console.group('[sugar-livewire] html');
|
|
81
|
+
console.log('perf:\n',performance.now());
|
|
82
|
+
console.log('before:\n', before);
|
|
83
|
+
console.log('after:\n', after);
|
|
84
|
+
console.log('changed:', before !== after);
|
|
85
|
+
console.log('handoff:\n', effects.html);
|
|
86
|
+
console.log('handoff is after:', effects.html === after);
|
|
87
|
+
console.groupEnd();
|
|
88
|
+
|
|
89
|
+
return original(effects);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
staging.remove();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
101
95
|
});
|
|
102
96
|
}
|
|
103
97
|
|
|
104
98
|
if (typeof document !== 'undefined') {
|
|
105
99
|
document.addEventListener('livewire:init', registerSugarLivewire);
|
|
106
|
-
|
|
107
100
|
queueMicrotask(() => {
|
|
108
101
|
registerSugarLivewire();
|
|
109
102
|
});
|
|
110
103
|
}
|
|
111
104
|
|
|
112
105
|
document.addEventListener('livewire:init', () => {
|
|
113
|
-
|
|
106
|
+
console.log('[sugar-livewire] livewire:init heard');
|
|
114
107
|
registerSugarLivewire();
|
|
115
108
|
});
|
|
116
109
|
|
|
117
110
|
queueMicrotask(() => {
|
|
118
|
-
|
|
111
|
+
console.log('[sugar-livewire] microtask fallback');
|
|
119
112
|
registerSugarLivewire();
|
|
120
113
|
});
|
|
@@ -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