atoms-element v5.0.0

#js

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.


c13e41fPeter John 2026-08-07T12:33:32+05:30
improve hydration
Files changed (2) hide show
  1. examples/e2e.spec.js +30 -0
  2. 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
  }