Skip to content

Commit 893c99f

Browse files
committed
feat: support mixed attribute interpolation in client renderer
Quoted attributes with embedded holes (class="static ${dynamic}") previously dropped the dynamic portion on client re-render. The renderer now tracks these as 'attr-mixed' parts that reconstruct the full attribute value from static pieces + dynamic values on each update. Implementation: when the first hole inside a quoted attribute is encountered, the attribute is replaced with a sentinel (same as regular attr). A skip-attr state consumes remaining characters and holes. On the closing quote, the first part is patched to attr-mixed with statics extracted from the template strings array.
1 parent 0d9be29 commit 893c99f

1 file changed

Lines changed: 74 additions & 14 deletions

File tree

packages/core/src/render-client.js

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ const INSTANCE = Symbol.for('webjs.instance');
3535

3636
/**
3737
* @typedef {{
38-
* kind: 'child' | 'attr' | 'event' | 'prop' | 'bool' | 'noop',
38+
* kind: 'child' | 'attr' | 'attr-mixed' | 'event' | 'prop' | 'bool' | 'noop',
3939
* path: number[],
4040
* name?: string,
41+
* statics?: string[],
42+
* group?: number[],
4143
* }} PartDescriptor
4244
*
4345
* @typedef {{
@@ -51,6 +53,7 @@ const INSTANCE = Symbol.for('webjs.instance');
5153
* @typedef {
5254
* | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
5355
* | { kind: 'attr', el: Element, name: string }
56+
* | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
5457
* | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
5558
* | { kind: 'prop', el: Element, name: string }
5659
* | { kind: 'bool', el: Element, name: string }
@@ -122,6 +125,8 @@ function compile(tr) {
122125
let attrStart = 0;
123126
let attrQuote = '';
124127
let commentDashes = 0;
128+
/** @type {{ name: string, firstPartIdx: number } | null} */
129+
let mixedAttr = null;
125130
let currentTag = '';
126131
let rawTail = '';
127132

@@ -205,6 +210,46 @@ function compile(tr) {
205210
html += c;
206211
if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
207212
break;
213+
case 'skip-attr':
214+
// Consume mixed-attribute chars without appending to html.
215+
// The attribute was replaced with a sentinel on the first hole.
216+
if (c === attrQuote) {
217+
// Closing quote — finalize the attr-mixed part.
218+
if (mixedAttr) {
219+
const idx0 = mixedAttr.firstPartIdx;
220+
const group = [];
221+
for (let k = idx0; k < parts.length; k++) {
222+
if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k);
223+
}
224+
// Build statics from the template strings array.
225+
// For `attr="a ${x} b ${y} c"`, group=[idx0,idx1].
226+
// statics[0] = tail of strings[idx0] after the `="`
227+
// statics[1] = strings[idx1] (between holes)
228+
// statics[n] = prefix of strings[last+1] up to closing quote
229+
const statics = [];
230+
const s0 = strings[group[0]];
231+
const qp = s0.lastIndexOf(attrQuote);
232+
statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
233+
for (let k = 1; k < group.length; k++) {
234+
statics.push(strings[group[k]]);
235+
}
236+
const sLast = strings[group[group.length - 1] + 1];
237+
const eq = sLast.indexOf(attrQuote);
238+
statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
239+
240+
parts[idx0] = {
241+
kind: 'attr-mixed',
242+
path: [],
243+
name: mixedAttr.name,
244+
statics,
245+
group,
246+
};
247+
mixedAttr = null;
248+
}
249+
state = 'in-tag';
250+
attrName = '';
251+
}
252+
break;
208253
}
209254
}
210255

@@ -250,13 +295,16 @@ function compile(tr) {
250295
state = 'in-tag';
251296
attrName = '';
252297
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
253-
// Interpolation inside a quoted attribute value (`attr="a${x}b"`) or
254-
// unquoted mixed value (`attr=a${x}b`). Fine-grained updates aren't
255-
// tracked in v1 — use the unquoted single-hole form `attr=${x}` for
256-
// values that need to update. Here we record a noop part so the
257-
// values[] length stays aligned with parts[], but the attribute text
258-
// in the compiled template is left as-is (SSR already wrote the right
259-
// value; the client just won't re-sync this attribute on re-render).
298+
// First hole inside a quoted attribute value — start mixed-attr tracking.
299+
// Replace the entire attribute with a sentinel (same as regular attr).
300+
html = html.slice(0, attrStart);
301+
const sentinel = `data-${MARKER}${partIdx}`;
302+
html += `${sentinel}=""`;
303+
mixedAttr = { name: attrName, firstPartIdx: partIdx };
304+
parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
305+
state = 'skip-attr';
306+
} else if (state === 'skip-attr') {
307+
// Subsequent hole in the same mixed attribute.
260308
parts.push({ kind: 'noop', path: [] });
261309
}
262310
}
@@ -339,7 +387,7 @@ function createInstance(tr, container) {
339387
const bound = parts.map((p) => bindPart(p, frag));
340388
const lastValues = [];
341389
for (let i = 0; i < tr.values.length; i++) {
342-
applyPart(bound[i], tr.values[i], undefined);
390+
applyPart(bound[i], tr.values[i], undefined, tr.values);
343391
lastValues.push(tr.values[i]);
344392
}
345393

@@ -374,6 +422,7 @@ function bindPart(p, root) {
374422
return part;
375423
}
376424
if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
425+
if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
377426
if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
378427
if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
379428
throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
@@ -387,7 +436,7 @@ function updateInstance(inst, values) {
387436
for (let i = 0; i < values.length; i++) {
388437
const next = values[i];
389438
if (Object.is(next, inst.lastValues[i])) continue;
390-
applyPart(inst.bound[i], next, inst.lastValues[i]);
439+
applyPart(inst.bound[i], next, inst.lastValues[i], values);
391440
inst.lastValues[i] = next;
392441
}
393442
}
@@ -413,7 +462,7 @@ function clearInstance(inst, container) {
413462
* @param {unknown} value
414463
* @param {unknown} _prev
415464
*/
416-
function applyPart(part, value, _prev) {
465+
function applyPart(part, value, _prev, allValues) {
417466
// Unwrap live() — dirty-check against the live DOM value, not the
418467
// last rendered value. Essential for <input> two-way binding.
419468
if (isLive(value)) {
@@ -443,6 +492,17 @@ function applyPart(part, value, _prev) {
443492
case 'event':
444493
part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
445494
break;
495+
case 'attr-mixed': {
496+
// Reconstruct the attribute from static pieces + all dynamic values.
497+
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
498+
let val = mp.statics[0];
499+
for (let j = 0; j < mp.group.length; j++) {
500+
val += String((allValues ? allValues[mp.group[j]] : value) ?? '');
501+
val += mp.statics[j + 1] || '';
502+
}
503+
part.el.setAttribute(part.name, val);
504+
break;
505+
}
446506
case 'noop':
447507
// intentionally empty — used for holes inside HTML comments
448508
break;
@@ -522,7 +582,7 @@ function applyChild(part, value) {
522582
const bound = parts.map((p) => bindPart(p, frag));
523583
const lastValues = [];
524584
for (let i = 0; i < tr.values.length; i++) {
525-
applyPart(bound[i], tr.values[i], undefined);
585+
applyPart(bound[i], tr.values[i], undefined, tr.values);
526586
lastValues.push(tr.values[i]);
527587
}
528588
const nodes = [startNode, ...frag.childNodes, endNode];
@@ -541,7 +601,7 @@ function applyChild(part, value) {
541601
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
542602
const bound = parts.map((p) => bindPart(p, frag));
543603
for (let i = 0; i < tr.values.length; i++) {
544-
applyPart(bound[i], tr.values[i], undefined);
604+
applyPart(bound[i], tr.values[i], undefined, tr.values);
545605
}
546606
list.push(...frag.childNodes);
547607
} else if (v != null && v !== false && v !== true) {
@@ -596,7 +656,7 @@ function buildDetached(tr) {
596656
const bound = parts.map((p) => bindPart(p, frag));
597657
const lastValues = [];
598658
for (let i = 0; i < tr.values.length; i++) {
599-
applyPart(bound[i], tr.values[i], undefined);
659+
applyPart(bound[i], tr.values[i], undefined, tr.values);
600660
lastValues.push(tr.values[i]);
601661
}
602662
const outFrag = document.createDocumentFragment();

0 commit comments

Comments
 (0)