Skip to content

Commit a39fdc8

Browse files
committed
feat(dom): implement morphlex to selectively update dom
1 parent 711c505 commit a39fdc8

File tree

6 files changed

+88
-63
lines changed

6 files changed

+88
-63
lines changed

bun.lock

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ilha/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"docs:build": "bunx docsome build DOCS.md --outDir docs_dist"
3030
},
3131
"dependencies": {
32-
"alien-signals": "^3.1.2"
32+
"alien-signals": "^3.1.2",
33+
"morphlex": "^1.4.0"
3334
},
3435
"devDependencies": {
3536
"docsome": "^0.0.6",

packages/ilha/src/index.ts

Lines changed: 59 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { 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-
502498
export 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
606601
interface 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);

templates/nitro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"preview": "vite preview"
1010
},
1111
"dependencies": {
12-
"ilha": "https://pkg.pr.new/guarana-studio/ilha@c9bcbd1",
12+
"ilha": "https://pkg.pr.new/guarana-studio/ilha@711c505",
1313
"nitro": "^3.0.260311-beta"
1414
},
1515
"devDependencies": {

templates/nitro/src/main.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
import ilha, { html, mount } from "ilha";
1+
import ilha, { html, mount, raw } from "ilha";
22

33
import "./style.css";
44

5-
const app = ilha.render(
6-
() => html`
7-
<p>ok</p>
8-
`,
9-
);
5+
const app = ilha
6+
.state("name", "Alice")
7+
.derived("greeting", async ({ state, signal }) => {
8+
const req = await fetch(`/hello?name=${state.name()}`, { signal });
9+
return req.text();
10+
})
11+
.bind("#name", "name")
12+
.render(
13+
({ derived }) => html`
14+
<input id="name" type="text" />
15+
${raw(derived.greeting.value ?? "")}
16+
`,
17+
);
1018

1119
mount({ app });
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import ilha, { html } from "ilha";
1+
import ilha, { html, type } from "ilha";
22
import { defineHandler } from "nitro";
3-
import * as h3 from "nitro/h3";
43

5-
const counter = ilha.state("count", 0).render(
6-
({ state }) =>
4+
const greet = ilha.input(type<{ name: string }>()).render(
5+
({ input }) =>
76
html`
8-
<p>Counter: ${state.count()}</p>
7+
<p>Hello, ${input.name}</p>
98
`,
109
);
1110

12-
export default defineHandler(async () => {
13-
return h3.html(await counter());
11+
export default defineHandler(async (event) => {
12+
const url = new URL(event.req.url);
13+
return greet({ name: url.searchParams.get("name") ?? "" });
1414
});

0 commit comments

Comments
 (0)