atoms-element v5.0.0
git clone https://git.pyrossh.dev/atoms-element
A simple web component library for defining your custom elements. It works on both client and server.
c13e41f
— Peter John
2026-08-07T12:33:32+05:30
improve hydration
- examples/e2e.spec.js +30 -0
- index.js +63 -1
examples/e2e.spec.js
CHANGED
|
@@ -66,3 +66,33 @@ test('clicking +/- updates the counter and the shared total reducer', async () =
|
|
|
66
66
|
test('no console/page errors were thrown', () => {
|
|
67
67
|
assert.deepStrictEqual(pageErrors, []);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
test('preserves focus across the first hydration render, without reusing the old DOM node', async () => {
|
|
71
|
+
const result = await page.evaluate(async () => {
|
|
72
|
+
const { createElement, html } = await import('/index.js');
|
|
73
|
+
const Comp = () => html`<div class="wrap"><button>-</button><button>+</button></div>`;
|
|
74
|
+
createElement({ url: '/focus-test-element.js' }, Comp);
|
|
75
|
+
|
|
76
|
+
const el = document.createElement('focus-test-element');
|
|
77
|
+
// fake SSR markup with the same shape the render function above produces
|
|
78
|
+
el.innerHTML = '<div class="wrap"><button>-</button><button>+</button></div>';
|
|
79
|
+
document.body.appendChild(el);
|
|
80
|
+
|
|
81
|
+
const originalPlus = el.querySelectorAll('button')[1];
|
|
82
|
+
originalPlus.focus();
|
|
83
|
+
const focusedBefore = document.activeElement === originalPlus;
|
|
84
|
+
|
|
85
|
+
// the hydration render is scheduled on a microtask by connectedCallback; wait for it
|
|
86
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
87
|
+
|
|
88
|
+
const newPlus = el.querySelectorAll('button')[1];
|
|
89
|
+
const focusedAfter = document.activeElement === newPlus;
|
|
90
|
+
const sameNode = originalPlus === newPlus;
|
|
91
|
+
el.remove();
|
|
92
|
+
return { focusedBefore, focusedAfter, sameNode };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert.strictEqual(result.focusedBefore, true, 'button should be focused before the hydration render runs');
|
|
96
|
+
assert.strictEqual(result.sameNode, false, 'the DOM node should have been discarded and recreated, not reused');
|
|
97
|
+
assert.strictEqual(result.focusedAfter, true, 'focus should be restored onto the equivalent new button');
|
|
98
|
+
});
|
index.js
CHANGED
|
@@ -524,6 +524,62 @@ export const useEffect = (fn, deps) => {
|
|
|
524
524
|
}
|
|
525
525
|
};
|
|
526
526
|
|
|
527
|
+
// Walks/matches by element position only, since lit-html injects its own comment
|
|
528
|
+
// marker nodes (part boundaries, per-binding markers) that don't exist in the
|
|
529
|
+
// server-rendered markup and would otherwise throw off a childNodes-based index.
|
|
530
|
+
const getNodePath = (root, node) => {
|
|
531
|
+
const path = [];
|
|
532
|
+
let current = node;
|
|
533
|
+
while (current && current !== root) {
|
|
534
|
+
const parent = current.parentElement;
|
|
535
|
+
if (!parent) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
path.unshift(Array.prototype.indexOf.call(parent.children, current));
|
|
539
|
+
current = parent;
|
|
540
|
+
}
|
|
541
|
+
return current === root ? path : null;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const getNodeAtPath = (root, path) => {
|
|
545
|
+
let current = root;
|
|
546
|
+
for (const index of path) {
|
|
547
|
+
current = current && current.children[index];
|
|
548
|
+
}
|
|
549
|
+
return current || null;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const captureFocusState = (root) => {
|
|
553
|
+
const active = document.activeElement;
|
|
554
|
+
if (!active || !root.contains(active)) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
const path = getNodePath(root, active);
|
|
558
|
+
if (!path) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const state = { path, tagName: active.tagName };
|
|
562
|
+
if (typeof active.selectionStart === 'number') {
|
|
563
|
+
state.selectionStart = active.selectionStart;
|
|
564
|
+
state.selectionEnd = active.selectionEnd;
|
|
565
|
+
}
|
|
566
|
+
return state;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const restoreFocusState = (root, state) => {
|
|
570
|
+
if (!state) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const next = getNodeAtPath(root, state.path);
|
|
574
|
+
if (!next || next.tagName !== state.tagName || typeof next.focus !== 'function') {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
next.focus();
|
|
578
|
+
if (state.selectionStart != null && typeof next.setSelectionRange === 'function') {
|
|
579
|
+
next.setSelectionRange(state.selectionStart, state.selectionEnd);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
527
583
|
const registry = {};
|
|
528
584
|
const injectedStyleTags = new Set();
|
|
529
585
|
export const getElement = (name) => registry[name];
|
|
@@ -646,10 +702,16 @@ export const createElement = (meta, renderFn) => {
|
|
|
646
702
|
// lit-html's render() appends into the container rather than replacing its
|
|
647
703
|
// content, so any server-rendered light-DOM markup must be discarded before
|
|
648
704
|
// the first client-side render or it's left behind as an inert duplicate.
|
|
705
|
+
// Discarding it drops focus/selection state on whatever was focused inside,
|
|
706
|
+
// so it's captured here and restored on the equivalent node afterward.
|
|
649
707
|
this._hydrated = true;
|
|
708
|
+
const focusState = captureFocusState(this);
|
|
650
709
|
this.textContent = '';
|
|
710
|
+
renderHtml(template, this);
|
|
711
|
+
restoreFocusState(this, focusState);
|
|
712
|
+
} else {
|
|
713
|
+
renderHtml(template, this);
|
|
651
714
|
}
|
|
652
|
-
renderHtml(template, this);
|
|
653
715
|
} else {
|
|
654
716
|
return renderHtml(template);
|
|
655
717
|
}
|