@@ -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