Skip to content

Commit ef975b3

Browse files
committed
feat(core): support dynamically updating bounding element for improved hmr
1 parent 6185f93 commit ef975b3

File tree

2 files changed

+86
-59
lines changed

2 files changed

+86
-59
lines changed

packages/core/src/lib/Adhesive.ts

Lines changed: 76 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
type ElementSelector = HTMLElement | string;
2-
31
/**
42
* Sticky positioning options.
53
*/
@@ -34,6 +32,8 @@ export const ADHESIVE_STATUS = {
3432
export type AdhesiveStatus =
3533
(typeof ADHESIVE_STATUS)[keyof typeof ADHESIVE_STATUS];
3634

35+
type ElementSelector = HTMLElement | string;
36+
3737
/**
3838
* Configuration options for Adhesive instances.
3939
*/
@@ -177,11 +177,39 @@ const DEFAULT_OPTIONS = {
177177
const isBrowser = () =>
178178
typeof window !== "undefined" && typeof document !== "undefined";
179179

180-
const resolveElement = (element: ElementSelector): HTMLElement | null => {
180+
const resolveElement = (selector: ElementSelector): HTMLElement | null => {
181181
if (!isBrowser()) return null;
182-
return typeof element === "string"
183-
? document.querySelector(element)
184-
: element;
182+
return typeof selector === "string"
183+
? document.querySelector(selector)
184+
: selector;
185+
};
186+
187+
const assertTargetElement = (
188+
selector: AdhesiveOptions["targetEl"],
189+
): HTMLElement => {
190+
if (!isBrowser()) return Object.create(null);
191+
192+
const element = resolveElement(selector);
193+
194+
if (!element) {
195+
throw new AdhesiveError("TARGET_EL_NOT_FOUND", "targetEl not found");
196+
}
197+
198+
return element;
199+
};
200+
201+
const assertBoundingElement = (
202+
selector: AdhesiveOptions["boundingEl"] | undefined,
203+
): HTMLElement => {
204+
if (!isBrowser()) return Object.create(null);
205+
206+
const element = selector ? resolveElement(selector) : document.body;
207+
208+
if (!element) {
209+
throw new AdhesiveError("BOUNDING_EL_NOT_FOUND", "boundingEl not found");
210+
}
211+
212+
return element;
185213
};
186214

187215
const getScrollTop = () =>
@@ -299,26 +327,11 @@ export class Adhesive {
299327

300328
this.#targetElSelector = options.targetEl;
301329
this.#boundingElSelector = options.boundingEl ?? null;
330+
this.#targetEl = assertTargetElement(this.#targetElSelector);
331+
this.#boundingEl = assertBoundingElement(this.#boundingElSelector);
302332

303-
if (!isBrowser()) {
304-
this.#targetEl = this.#boundingEl = Object.create(null);
305-
return;
306-
}
307-
308-
const targetEl = resolveElement(options.targetEl);
309-
const boundingEl = options.boundingEl
310-
? resolveElement(options.boundingEl)
311-
: document.body;
312-
313-
if (!targetEl) {
314-
throw new AdhesiveError("TARGET_EL_NOT_FOUND", "targetEl not found");
315-
}
316-
if (!boundingEl) {
317-
throw new AdhesiveError("BOUNDING_EL_NOT_FOUND", "boundingEl not found");
318-
}
333+
if (!isBrowser()) return;
319334

320-
this.#targetEl = targetEl;
321-
this.#boundingEl = boundingEl;
322335
this.#options.enabled = options.enabled ?? DEFAULT_OPTIONS.enabled;
323336
this.#options.offset = options.offset ?? DEFAULT_OPTIONS.offset;
324337
this.#options.position = options.position ?? DEFAULT_OPTIONS.position;
@@ -361,26 +374,8 @@ export class Adhesive {
361374
if (!this.#outerWrapper && !this.#innerWrapper) {
362375
// Re-resolve elements if transitioning from SSR to browser
363376
if (!this.#targetEl.parentNode) {
364-
const targetEl = resolveElement(this.#targetElSelector);
365-
const boundingEl = this.#boundingElSelector
366-
? resolveElement(this.#boundingElSelector)
367-
: document.body;
368-
369-
if (!targetEl) {
370-
throw new AdhesiveError(
371-
"TARGET_EL_NOT_FOUND",
372-
"targetEl not found",
373-
);
374-
}
375-
if (!boundingEl) {
376-
throw new AdhesiveError(
377-
"BOUNDING_EL_NOT_FOUND",
378-
"boundingEl not found",
379-
);
380-
}
381-
382-
this.#targetEl = targetEl;
383-
this.#boundingEl = boundingEl;
377+
this.#targetEl = assertTargetElement(this.#targetElSelector);
378+
this.#boundingEl = assertBoundingElement(this.#boundingElSelector);
384379
}
385380

386381
this.#createWrappers();
@@ -405,12 +400,14 @@ export class Adhesive {
405400
/**
406401
* Update configuration options (partial update).
407402
*/
408-
updateOptions(
409-
newOptions: Partial<Omit<AdhesiveOptions, "targetEl" | "boundingEl">>,
410-
): this {
403+
updateOptions(newOptions: Partial<Omit<AdhesiveOptions, "targetEl">>): this {
411404
if (newOptions.enabled === false) return this.disable();
412405
if (newOptions.enabled === true) this.enable();
413406

407+
const currentBoundingEl = this.#boundingEl;
408+
409+
if (newOptions.boundingEl !== undefined)
410+
this.#boundingEl = assertBoundingElement(newOptions.boundingEl);
414411
if (newOptions.offset !== undefined)
415412
this.#options.offset = newOptions.offset;
416413
if (newOptions.position) this.#options.position = newOptions.position;
@@ -427,6 +424,8 @@ export class Adhesive {
427424
if (newOptions.relativeClassName !== undefined)
428425
this.#options.relativeClassName = newOptions.relativeClassName;
429426

427+
if (currentBoundingEl !== this.#boundingEl) this.#refreshListeners();
428+
430429
this.#update();
431430
this.#rerender();
432431
return this;
@@ -435,12 +434,13 @@ export class Adhesive {
435434
/**
436435
* Replace configuration options (full update).
437436
*/
438-
replaceOptions(
439-
newOptions: Omit<AdhesiveOptions, "targetEl" | "boundingEl">,
440-
): this {
437+
replaceOptions(newOptions: Omit<AdhesiveOptions, "targetEl">): this {
441438
if (newOptions.enabled === false) return this.disable();
442439
if (newOptions.enabled === true) this.enable();
443440

441+
const currentBoundingEl = this.#boundingEl;
442+
443+
this.#boundingEl = assertBoundingElement(newOptions.boundingEl);
444444
this.#options.offset = newOptions.offset ?? DEFAULT_OPTIONS.offset;
445445
this.#options.position = newOptions.position ?? DEFAULT_OPTIONS.position;
446446
this.#options.zIndex = newOptions.zIndex ?? DEFAULT_OPTIONS.zIndex;
@@ -455,6 +455,8 @@ export class Adhesive {
455455
this.#options.relativeClassName =
456456
newOptions.relativeClassName ?? DEFAULT_OPTIONS.relativeClassName;
457457

458+
if (currentBoundingEl !== this.#boundingEl) this.#refreshListeners();
459+
458460
this.#update();
459461
this.#rerender();
460462
return this;
@@ -482,11 +484,7 @@ export class Adhesive {
482484
if (!isBrowser()) return;
483485

484486
this.#cancelRAF();
485-
window.removeEventListener("scroll", this.#onScroll);
486-
window.removeEventListener("resize", this.#onResize);
487-
this.#observer?.disconnect();
488-
this.#observer = null;
489-
this.#trackedElements.clear();
487+
this.#cleanupListeners();
490488
this.#setInitial();
491489
this.#state.activated = false;
492490

@@ -528,6 +526,8 @@ export class Adhesive {
528526
}
529527

530528
#setupListeners(): void {
529+
if (!isBrowser()) return;
530+
531531
window.addEventListener("scroll", this.#onScroll, { passive: true });
532532
window.addEventListener("resize", this.#onResize, { passive: true });
533533

@@ -547,6 +547,23 @@ export class Adhesive {
547547
}
548548
}
549549

550+
#cleanupListeners(): void {
551+
if (!isBrowser()) return;
552+
553+
window.removeEventListener("scroll", this.#onScroll);
554+
window.removeEventListener("resize", this.#onResize);
555+
this.#observer?.disconnect();
556+
this.#observer = null;
557+
this.#trackedElements.clear();
558+
}
559+
560+
#refreshListeners(): void {
561+
if (!isBrowser()) return;
562+
563+
this.#cleanupListeners();
564+
this.#setupListeners();
565+
}
566+
550567
#onScroll = (): void => {
551568
this.#scheduleUpdate();
552569
};
@@ -625,15 +642,15 @@ export class Adhesive {
625642
}
626643

627644
#getTopBoundary(): number {
628-
if (!isBrowser() || this.#boundingEl === document.body) return 0;
645+
if (this.#boundingEl === document.body) return 0;
646+
629647
const rect = this.#boundingEl.getBoundingClientRect();
630648
return getScrollTop() + rect.top;
631649
}
632650

633651
#getBottomBoundary(): number {
634-
if (!isBrowser() || this.#boundingEl === document.body) {
635-
return Number.POSITIVE_INFINITY;
636-
}
652+
if (this.#boundingEl === document.body) return Number.POSITIVE_INFINITY;
653+
637654
const rect = this.#boundingEl.getBoundingClientRect();
638655
return getScrollTop() + rect.bottom;
639656
}

test/unit/core.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,16 @@ describe("Core", () => {
659659

660660
adhesive.cleanup();
661661
});
662+
663+
it("updates bounding element without breaking functionality", () => {
664+
const adhesive = createInitializedAdhesive({ boundingEl });
665+
666+
expect(() =>
667+
adhesive.replaceOptions({ boundingEl: null }),
668+
).not.toThrow();
669+
670+
adhesive.cleanup();
671+
});
662672
});
663673

664674
describe("DOM restoration", () => {

0 commit comments

Comments
 (0)