11import { signal , effect , setActiveSub } from "alien-signals" ;
2+ import { morphInner } from "morphlex" ;
23
34// ─────────────────────────────────────────────
45// Standard Schema V1 (inlined, type-only)
@@ -394,7 +395,6 @@ function applyBindings<TStateMap extends Record<string, unknown>>(
394395 const isRadio =
395396 ( target as HTMLInputElement ) . tagName . toLowerCase ( ) === "input" &&
396397 ( target as HTMLInputElement ) . type ?. toLowerCase ( ) === "radio" ;
397-
398398 write ( target , accessor ( ) ) ;
399399
400400 const listener = ( ) => {
@@ -495,10 +495,6 @@ type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
495495 host : Element ;
496496} ;
497497
498- // ─────────────────────────────────────────────
499- // OnMountContext — now includes derived
500- // ─────────────────────────────────────────────
501-
502498export type OnMountContext <
503499 TInput ,
504500 TStateMap extends Record < string , unknown > ,
@@ -602,7 +598,6 @@ interface EffectEntry<TInput, TStateMap extends Record<string, unknown>> {
602598 fn : ( ctx : EffectContext < TInput , TStateMap > ) => ( ( ) => void ) | void ;
603599}
604600
605- // OnMountEntry is now generic over TDerivedMap too
606601interface OnMountEntry <
607602 TInput ,
608603 TStateMap extends Record < string , unknown > ,
@@ -798,7 +793,6 @@ class IlhaBuilder<
798793 ) ;
799794 }
800795
801- // onMount now threads TDerivedMap through so the callback sees derived
802796 onMount (
803797 fn : ( ctx : OnMountContext < TInput , TStateMap , TDerivedMap > ) => ( ( ) => void ) | void ,
804798 ) : IlhaBuilder < TInput , TStateMap , TDerivedMap , TSlots > {
@@ -865,7 +859,7 @@ class IlhaBuilder<
865859 return validateSchema ( schema , value ) as TInput ;
866860 }
867861
868- function makeSlotsProxy ( ssr : boolean ) : SlotsProxy < TSlots > {
862+ function makeSlotsProxy ( ssr : boolean , host ?: Element ) : SlotsProxy < TSlots > {
869863 return new Proxy (
870864 { } ,
871865 {
@@ -878,6 +872,12 @@ class IlhaBuilder<
878872 ) ;
879873 }
880874 return makeSlotAccessor ( ( props ?: Record < string , unknown > ) => {
875+ // On re-renders, mirror the live slot element's current outerHTML
876+ // back into the string so morphlex sees no diff and leaves it alone.
877+ const liveSlot = host ?. querySelector ( `[${ SLOT_ATTR } ="${ escapeHtml ( name ) } "]` ) ;
878+ if ( liveSlot ) return liveSlot . outerHTML ;
879+
880+ // First render — emit the empty placeholder as before
881881 const json = props ? ` ${ PROPS_ATTR } ='${ escapeHtml ( JSON . stringify ( props ) ) } '` : "" ;
882882 return `<div ${ SLOT_ATTR } ="${ escapeHtml ( name ) } "${ json } ></div>` ;
883883 } ) ;
@@ -994,7 +994,6 @@ class IlhaBuilder<
994994 }
995995
996996 function mountIsland ( host : Element , props ?: Partial < TInput > ) : ( ) => void {
997- // If caller didn't pass props, fall back to data-ilha-props attribute
998997 if ( props === undefined ) {
999998 const rawProps = host . getAttribute ( PROPS_ATTR ) ;
1000999 if ( rawProps ) {
@@ -1018,7 +1017,6 @@ class IlhaBuilder<
10181017 }
10191018 }
10201019
1021- // Split snapshot into state signals and derived envelopes
10221020 const stateSnapshot = snapshotRaw
10231021 ? ( Object . fromEntries (
10241022 Object . entries ( snapshotRaw ) . filter ( ( [ k ] ) => k !== "_derived" && k !== "_skipOnMount" ) ,
@@ -1045,20 +1043,37 @@ class IlhaBuilder<
10451043 > ( deriveds as DerivedEntry < TInput , TStateMap > [ ] , state , input , derivedSnapshot ) ;
10461044 cleanups . push ( stopDerived ) ;
10471045
1048- const slotEls = new Map < string , Element > ( ) ;
1049-
1050- function snapshotSlots ( ) {
1051- slotEls . clear ( ) ;
1052- for ( const name of Object . keys ( slotDefs ) ) {
1053- const existing = host . querySelector ( `[${ SLOT_ATTR } ="${ name } "]` ) ;
1054- if ( existing ) slotEls . set ( name , existing ) ;
1055- }
1056- }
1046+ // ─── slot bookkeeping ───────────────────────────────────────────────
1047+ // Idiomorph morphs slot placeholder divs in-place, so we no longer need
1048+ // to snapshot/restore them manually — morph preserves existing DOM nodes
1049+ // that match by identity. We still track mounted child islands so we can
1050+ // unmount them on cleanup.
1051+ const slotCleanups = new Map < string , ( ) => void > ( ) ;
1052+ const slotEls = new Map < string , Element > ( ) ; // ← track live element refs
1053+
1054+ function mountSlots ( ) {
1055+ for ( const [ name , childIsland ] of Object . entries ( slotDefs ) ) {
1056+ const slotEl = host . querySelector ( `[${ SLOT_ATTR } ="${ name } "]` ) ;
1057+ if ( ! slotEl ) continue ;
1058+
1059+ // If morphlex kept the same node alive, the child island is still
1060+ // running — don't remount it.
1061+ if ( slotEls . get ( name ) === slotEl ) continue ;
1062+
1063+ slotEls . set ( name , slotEl ) ;
1064+ slotCleanups . get ( name ) ?.( ) ;
1065+
1066+ let slotProps : Record < string , unknown > | undefined ;
1067+ const rawProps = slotEl . getAttribute ( PROPS_ATTR ) ?? slotEl . getAttribute ( "data-props" ) ;
1068+ if ( rawProps ) {
1069+ try {
1070+ slotProps = JSON . parse ( rawProps ) as Record < string , unknown > ;
1071+ } catch {
1072+ console . warn ( `[ilha] Failed to parse props on [${ SLOT_ATTR } ="${ name } "]` ) ;
1073+ }
1074+ }
10571075
1058- function restoreSlots ( ) {
1059- for ( const [ name , slotEl ] of slotEls ) {
1060- const placeholder = host . querySelector ( `[${ SLOT_ATTR } ="${ name } "]` ) ;
1061- if ( placeholder ) placeholder . replaceWith ( slotEl ) ;
1076+ slotCleanups . set ( name , childIsland . mount ( slotEl , slotProps ) ) ;
10621077 }
10631078 }
10641079
@@ -1114,34 +1129,20 @@ class IlhaBuilder<
11141129 listeners . length = 0 ;
11151130 }
11161131
1117- const slots = makeSlotsProxy ( false ) ;
1132+ const slots = makeSlotsProxy ( false , host ) ;
11181133
1134+ // ─── initial render ─────────────────────────────────────────────────
1135+ // First paint can still use innerHTML — nothing to preserve yet.
11191136 host . innerHTML = fn ( { state, derived, input, slots } ) ;
11201137 attachListeners ( ) ;
11211138
11221139 let stopBindings = applyBindings ( host , binds as BindEntry < TStateMap > [ ] , state ) ;
11231140 cleanups . push ( ( ) => stopBindings ( ) ) ;
11241141
1125- for ( const [ name , childIsland ] of Object . entries ( slotDefs ) ) {
1126- const slotEl = host . querySelector ( `[${ SLOT_ATTR } ="${ name } "]` ) ;
1127- if ( ! slotEl ) continue ;
1128- slotEls . set ( name , slotEl ) ;
1142+ mountSlots ( ) ;
1143+ cleanups . push ( ( ) => slotCleanups . forEach ( ( unmount ) => unmount ( ) ) ) ;
11291144
1130- let slotProps : Record < string , unknown > | undefined ;
1131- // try data-ilha-props first, then data-props as fallback
1132- const rawProps = slotEl . getAttribute ( PROPS_ATTR ) ?? slotEl . getAttribute ( "data-props" ) ;
1133- if ( rawProps ) {
1134- try {
1135- slotProps = JSON . parse ( rawProps ) as Record < string , unknown > ;
1136- } catch {
1137- console . warn ( `[ilha] Failed to parse props on [${ SLOT_ATTR } ="${ name } "]` ) ;
1138- }
1139- }
1140-
1141- cleanups . push ( childIsland . mount ( slotEl , slotProps ) ) ;
1142- }
1143-
1144- // Run onMount callbacks — derived is now passed in ctx
1145+ // Run onMount callbacks
11451146 for ( const entry of onMounts ) {
11461147 const prevSub = setActiveSub ( undefined ) ;
11471148 let userCleanup : ( ( ) => void ) | void ;
@@ -1153,20 +1154,32 @@ class IlhaBuilder<
11531154 if ( userCleanup ) cleanups . push ( userCleanup ) ;
11541155 }
11551156
1157+ // ─── reactive re-render via morph ────────────────────────────────────
1158+ // Idiomorph diffs the new HTML string against the live DOM, patching only
1159+ // what changed. Focused inputs, scroll positions, and child island nodes
1160+ // survive intact. After each morph we re-attach listeners and bindings
1161+ // (same as before) and re-mount any slot children whose placeholder node
1162+ // may have been recreated by the morph.
11561163 let initialized = false ;
11571164 const stopRender = effect ( ( ) => {
11581165 const html = fn ( { state, derived, input, slots } ) ;
11591166 if ( ! initialized ) {
11601167 initialized = true ;
11611168 return ;
11621169 }
1163- snapshotSlots ( ) ;
1170+
11641171 detachListeners ( ) ;
11651172 stopBindings ( ) ;
1166- host . innerHTML = html ;
1167- restoreSlots ( ) ;
1173+
1174+ const tpl = document . createElement ( "template" ) ;
1175+ tpl . innerHTML = `<div>${ html } </div>` ;
1176+ morphInner ( host , tpl . content . firstElementChild as Element ) ;
1177+
11681178 attachListeners ( ) ;
11691179 stopBindings = applyBindings ( host , binds as BindEntry < TStateMap > [ ] , state ) ;
1180+
1181+ // Re-mount slots whose placeholder may have been morphed away
1182+ mountSlots ( ) ;
11701183 } ) ;
11711184 cleanups . push ( stopRender ) ;
11721185 cleanups . push ( detachListeners ) ;
0 commit comments