@hotwired/turbo 8.0.0-beta.2 → 8.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /*!
2
- Turbo 8.0.0-beta.2
3
- Copyright © 2023 37signals LLC
2
+ Turbo 8.0.0-beta.3
3
+ Copyright © 2024 37signals LLC
4
4
  */
5
5
  (function (global, factory) {
6
6
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
@@ -639,6 +639,34 @@ Copyright © 2023 37signals LLC
639
639
  return [before, after]
640
640
  }
641
641
 
642
+ function doesNotTargetIFrame(anchor) {
643
+ if (anchor.hasAttribute("target")) {
644
+ for (const element of document.getElementsByName(anchor.target)) {
645
+ if (element instanceof HTMLIFrameElement) return false
646
+ }
647
+ }
648
+
649
+ return true
650
+ }
651
+
652
+ function findLinkFromClickTarget(target) {
653
+ return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
654
+ }
655
+
656
+ function getLocationForLink(link) {
657
+ return expandURL(link.getAttribute("href") || "")
658
+ }
659
+
660
+ function debounce(fn, delay) {
661
+ let timeoutId = null;
662
+
663
+ return (...args) => {
664
+ const callback = () => fn.apply(this, args);
665
+ clearTimeout(timeoutId);
666
+ timeoutId = setTimeout(callback, delay);
667
+ }
668
+ }
669
+
642
670
  class LimitedSet extends Set {
643
671
  constructor(maxSize) {
644
672
  super();
@@ -789,10 +817,17 @@ Copyright © 2023 37signals LLC
789
817
  async perform() {
790
818
  const { fetchOptions } = this;
791
819
  this.delegate.prepareRequest(this);
792
- await this.#allowRequestToBeIntercepted(fetchOptions);
820
+ const event = await this.#allowRequestToBeIntercepted(fetchOptions);
793
821
  try {
794
822
  this.delegate.requestStarted(this);
795
- const response = await fetchWithTurboHeaders(this.url.href, fetchOptions);
823
+
824
+ if (event.detail.fetchRequest) {
825
+ this.response = event.detail.fetchRequest.response;
826
+ } else {
827
+ this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
828
+ }
829
+
830
+ const response = await this.response;
796
831
  return await this.receive(response)
797
832
  } catch (error) {
798
833
  if (error.name !== "AbortError") {
@@ -854,6 +889,8 @@ Copyright © 2023 37signals LLC
854
889
  });
855
890
  this.url = event.detail.url;
856
891
  if (event.defaultPrevented) await requestInterception;
892
+
893
+ return event
857
894
  }
858
895
 
859
896
  #willDelegateErrorHandling(error) {
@@ -964,6 +1001,41 @@ Copyright © 2023 37signals LLC
964
1001
  return fragment
965
1002
  }
966
1003
 
1004
+ const PREFETCH_DELAY = 100;
1005
+
1006
+ class PrefetchCache {
1007
+ #prefetchTimeout = null
1008
+ #prefetched = null
1009
+
1010
+ get(url) {
1011
+ if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
1012
+ return this.#prefetched.request
1013
+ }
1014
+ }
1015
+
1016
+ setLater(url, request, ttl) {
1017
+ this.clear();
1018
+
1019
+ this.#prefetchTimeout = setTimeout(() => {
1020
+ request.perform();
1021
+ this.set(url, request, ttl);
1022
+ this.#prefetchTimeout = null;
1023
+ }, PREFETCH_DELAY);
1024
+ }
1025
+
1026
+ set(url, request, ttl) {
1027
+ this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
1028
+ }
1029
+
1030
+ clear() {
1031
+ if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
1032
+ this.#prefetched = null;
1033
+ }
1034
+ }
1035
+
1036
+ const cacheTtl = 10 * 1000;
1037
+ const prefetchCache = new PrefetchCache();
1038
+
967
1039
  const FormSubmissionState = {
968
1040
  initialized: "initialized",
969
1041
  requesting: "requesting",
@@ -1081,13 +1153,20 @@ Copyright © 2023 37signals LLC
1081
1153
  }
1082
1154
 
1083
1155
  requestPreventedHandlingResponse(request, response) {
1156
+ prefetchCache.clear();
1157
+
1084
1158
  this.result = { success: response.succeeded, fetchResponse: response };
1085
1159
  }
1086
1160
 
1087
1161
  requestSucceededWithResponse(request, response) {
1088
1162
  if (response.clientError || response.serverError) {
1089
1163
  this.delegate.formSubmissionFailedWithResponse(this, response);
1090
- } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1164
+ return
1165
+ }
1166
+
1167
+ prefetchCache.clear();
1168
+
1169
+ if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1091
1170
  const error = new Error("Form responses must redirect to another location");
1092
1171
  this.delegate.formSubmissionErrored(this, error);
1093
1172
  } else {
@@ -1405,7 +1484,7 @@ Copyright © 2023 37signals LLC
1405
1484
 
1406
1485
  const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
1407
1486
  const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement };
1408
- const immediateRender = this.delegate.allowsImmediateRender(snapshot, isPreview, options);
1487
+ const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
1409
1488
  if (!immediateRender) await renderInterception;
1410
1489
 
1411
1490
  await this.renderSnapshot(renderer);
@@ -1543,9 +1622,9 @@ Copyright © 2023 37signals LLC
1543
1622
  clickBubbled = (event) => {
1544
1623
  if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
1545
1624
  const target = (event.composedPath && event.composedPath()[0]) || event.target;
1546
- const link = this.findLinkFromClickTarget(target);
1625
+ const link = findLinkFromClickTarget(target);
1547
1626
  if (link && doesNotTargetIFrame(link)) {
1548
- const location = this.getLocationForLink(link);
1627
+ const location = getLocationForLink(link);
1549
1628
  if (this.delegate.willFollowLinkToLocation(link, location, event)) {
1550
1629
  event.preventDefault();
1551
1630
  this.delegate.followedLinkToLocation(link, location);
@@ -1565,26 +1644,6 @@ Copyright © 2023 37signals LLC
1565
1644
  event.shiftKey
1566
1645
  )
1567
1646
  }
1568
-
1569
- findLinkFromClickTarget(target) {
1570
- return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
1571
- }
1572
-
1573
- getLocationForLink(link) {
1574
- return expandURL(link.getAttribute("href") || "")
1575
- }
1576
- }
1577
-
1578
- function doesNotTargetIFrame(anchor) {
1579
- if (anchor.hasAttribute("target")) {
1580
- for (const element of document.getElementsByName(anchor.target)) {
1581
- if (element instanceof HTMLIFrameElement) return false
1582
- }
1583
-
1584
- return true
1585
- } else {
1586
- return true
1587
- }
1588
1647
  }
1589
1648
 
1590
1649
  class FormLinkClickObserver {
@@ -1601,6 +1660,16 @@ Copyright © 2023 37signals LLC
1601
1660
  this.linkInterceptor.stop();
1602
1661
  }
1603
1662
 
1663
+ // Link hover observer delegate
1664
+
1665
+ canPrefetchRequestToLocation(link, location) {
1666
+ return false
1667
+ }
1668
+
1669
+ prefetchAndCacheRequestToLocation(link, location) {
1670
+ return
1671
+ }
1672
+
1604
1673
  // Link click observer delegate
1605
1674
 
1606
1675
  willFollowLinkToLocation(link, location, originalEvent) {
@@ -1874,6 +1943,8 @@ Copyright © 2023 37signals LLC
1874
1943
  }
1875
1944
  }
1876
1945
 
1946
+ const ProgressBarID = "turbo-progress-bar";
1947
+
1877
1948
  class ProgressBar {
1878
1949
  static animationDuration = 300 /*ms*/
1879
1950
 
@@ -1978,6 +2049,8 @@ Copyright © 2023 37signals LLC
1978
2049
 
1979
2050
  createStylesheetElement() {
1980
2051
  const element = document.createElement("style");
2052
+ element.id = ProgressBarID;
2053
+ element.setAttribute("data-turbo-permanent", "");
1981
2054
  element.type = "text/css";
1982
2055
  element.textContent = ProgressBar.defaultCSS;
1983
2056
  if (this.cspNonce) {
@@ -3014,6 +3087,176 @@ Copyright © 2023 37signals LLC
3014
3087
  }
3015
3088
  }
3016
3089
 
3090
+ class LinkPrefetchObserver {
3091
+ started = false
3092
+ hoverTriggerEvent = "mouseenter"
3093
+ touchTriggerEvent = "touchstart"
3094
+
3095
+ constructor(delegate, eventTarget) {
3096
+ this.delegate = delegate;
3097
+ this.eventTarget = eventTarget;
3098
+ }
3099
+
3100
+ start() {
3101
+ if (this.started) return
3102
+
3103
+ if (this.eventTarget.readyState === "loading") {
3104
+ this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true });
3105
+ } else {
3106
+ this.#enable();
3107
+ }
3108
+ }
3109
+
3110
+ stop() {
3111
+ if (!this.started) return
3112
+
3113
+ this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3114
+ capture: true,
3115
+ passive: true
3116
+ });
3117
+ this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3118
+ capture: true,
3119
+ passive: true
3120
+ });
3121
+ this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3122
+ this.started = false;
3123
+ }
3124
+
3125
+ #enable = () => {
3126
+ this.eventTarget.addEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3127
+ capture: true,
3128
+ passive: true
3129
+ });
3130
+ this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3131
+ capture: true,
3132
+ passive: true
3133
+ });
3134
+ this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3135
+ this.started = true;
3136
+ }
3137
+
3138
+ #tryToPrefetchRequest = (event) => {
3139
+ if (getMetaContent("turbo-prefetch") !== "true") return
3140
+
3141
+ const target = event.target;
3142
+ const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])");
3143
+
3144
+ if (isLink && this.#isPrefetchable(target)) {
3145
+ const link = target;
3146
+ const location = getLocationForLink(link);
3147
+
3148
+ if (this.delegate.canPrefetchRequestToLocation(link, location)) {
3149
+ const fetchRequest = new FetchRequest(
3150
+ this,
3151
+ FetchMethod.get,
3152
+ location,
3153
+ new URLSearchParams(),
3154
+ target
3155
+ );
3156
+
3157
+ prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
3158
+
3159
+ link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true });
3160
+ }
3161
+ }
3162
+ }
3163
+
3164
+ #tryToUsePrefetchedRequest = (event) => {
3165
+ if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") {
3166
+ const cached = prefetchCache.get(event.detail.url.toString());
3167
+
3168
+ if (cached) {
3169
+ // User clicked link, use cache response
3170
+ event.detail.fetchRequest = cached;
3171
+ }
3172
+
3173
+ prefetchCache.clear();
3174
+ }
3175
+ }
3176
+
3177
+ prepareRequest(request) {
3178
+ const link = request.target;
3179
+
3180
+ request.headers["Sec-Purpose"] = "prefetch";
3181
+
3182
+ if (link.dataset.turboFrame && link.dataset.turboFrame !== "_top") {
3183
+ request.headers["Turbo-Frame"] = link.dataset.turboFrame;
3184
+ } else if (link.dataset.turboFrame !== "_top") {
3185
+ const turboFrame = link.closest("turbo-frame");
3186
+
3187
+ if (turboFrame) {
3188
+ request.headers["Turbo-Frame"] = turboFrame.id;
3189
+ }
3190
+ }
3191
+
3192
+ if (link.hasAttribute("data-turbo-stream")) {
3193
+ request.acceptResponseType("text/vnd.turbo-stream.html");
3194
+ }
3195
+ }
3196
+
3197
+ // Fetch request interface
3198
+
3199
+ requestSucceededWithResponse() {}
3200
+
3201
+ requestStarted(fetchRequest) {}
3202
+
3203
+ requestErrored(fetchRequest) {}
3204
+
3205
+ requestFinished(fetchRequest) {}
3206
+
3207
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
3208
+
3209
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
3210
+
3211
+ get #cacheTtl() {
3212
+ return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
3213
+ }
3214
+
3215
+ #isPrefetchable(link) {
3216
+ const href = link.getAttribute("href");
3217
+
3218
+ if (!href || href === "#" || link.dataset.turbo === "false" || link.dataset.turboPrefetch === "false") {
3219
+ return false
3220
+ }
3221
+
3222
+ if (link.origin !== document.location.origin) {
3223
+ return false
3224
+ }
3225
+
3226
+ if (!["http:", "https:"].includes(link.protocol)) {
3227
+ return false
3228
+ }
3229
+
3230
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3231
+ return false
3232
+ }
3233
+
3234
+ if (link.dataset.turboMethod && link.dataset.turboMethod !== "get") {
3235
+ return false
3236
+ }
3237
+
3238
+ if (targetsIframe(link)) {
3239
+ return false
3240
+ }
3241
+
3242
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3243
+ return false
3244
+ }
3245
+
3246
+ const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]");
3247
+
3248
+ if (turboPrefetchParent && turboPrefetchParent.dataset.turboPrefetch === "false") {
3249
+ return false
3250
+ }
3251
+
3252
+ return true
3253
+ }
3254
+ }
3255
+
3256
+ const targetsIframe = (link) => {
3257
+ return !doesNotTargetIFrame(link)
3258
+ };
3259
+
3017
3260
  class Navigator {
3018
3261
  constructor(delegate) {
3019
3262
  this.delegate = delegate;
@@ -3484,722 +3727,838 @@ Copyright © 2023 37signals LLC
3484
3727
  }
3485
3728
  }
3486
3729
 
3487
- let EMPTY_SET = new Set();
3730
+ // base IIFE to define idiomorph
3731
+ var Idiomorph = (function () {
3488
3732
 
3489
- //=============================================================================
3490
- // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3491
- //=============================================================================
3492
- function morph(oldNode, newContent, config = {}) {
3733
+ //=============================================================================
3734
+ // AND NOW IT BEGINS...
3735
+ //=============================================================================
3736
+ let EMPTY_SET = new Set();
3493
3737
 
3494
- if (oldNode instanceof Document) {
3495
- oldNode = oldNode.documentElement;
3496
- }
3738
+ // default configuration values, updatable by users now
3739
+ let defaults = {
3740
+ morphStyle: "outerHTML",
3741
+ callbacks : {
3742
+ beforeNodeAdded: noOp,
3743
+ afterNodeAdded: noOp,
3744
+ beforeNodeMorphed: noOp,
3745
+ afterNodeMorphed: noOp,
3746
+ beforeNodeRemoved: noOp,
3747
+ afterNodeRemoved: noOp,
3748
+ beforeAttributeUpdated: noOp,
3497
3749
 
3498
- if (typeof newContent === 'string') {
3499
- newContent = parseContent(newContent);
3500
- }
3501
-
3502
- let normalizedContent = normalizeContent(newContent);
3503
-
3504
- let ctx = createMorphContext(oldNode, normalizedContent, config);
3505
-
3506
- return morphNormalizedContent(oldNode, normalizedContent, ctx);
3507
- }
3508
-
3509
- function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3510
- if (ctx.head.block) {
3511
- let oldHead = oldNode.querySelector('head');
3512
- let newHead = normalizedNewContent.querySelector('head');
3513
- if (oldHead && newHead) {
3514
- let promises = handleHeadElement(newHead, oldHead, ctx);
3515
- // when head promises resolve, call morph again, ignoring the head tag
3516
- Promise.all(promises).then(function () {
3517
- morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3518
- head: {
3519
- block: false,
3520
- ignore: true
3521
- }
3522
- }));
3523
- });
3524
- return;
3525
- }
3526
- }
3750
+ },
3751
+ head: {
3752
+ style: 'merge',
3753
+ shouldPreserve: function (elt) {
3754
+ return elt.getAttribute("im-preserve") === "true";
3755
+ },
3756
+ shouldReAppend: function (elt) {
3757
+ return elt.getAttribute("im-re-append") === "true";
3758
+ },
3759
+ shouldRemove: noOp,
3760
+ afterHeadMorphed: noOp,
3761
+ }
3762
+ };
3527
3763
 
3528
- if (ctx.morphStyle === "innerHTML") {
3764
+ //=============================================================================
3765
+ // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3766
+ //=============================================================================
3767
+ function morph(oldNode, newContent, config = {}) {
3529
3768
 
3530
- // innerHTML, so we are only updating the children
3531
- morphChildren(normalizedNewContent, oldNode, ctx);
3532
- return oldNode.children;
3769
+ if (oldNode instanceof Document) {
3770
+ oldNode = oldNode.documentElement;
3771
+ }
3533
3772
 
3534
- } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3535
- // otherwise find the best element match in the new content, morph that, and merge its siblings
3536
- // into either side of the best match
3537
- let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3773
+ if (typeof newContent === 'string') {
3774
+ newContent = parseContent(newContent);
3775
+ }
3538
3776
 
3539
- // stash the siblings that will need to be inserted on either side of the best match
3540
- let previousSibling = bestMatch?.previousSibling;
3541
- let nextSibling = bestMatch?.nextSibling;
3777
+ let normalizedContent = normalizeContent(newContent);
3542
3778
 
3543
- // morph it
3544
- let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3779
+ let ctx = createMorphContext(oldNode, normalizedContent, config);
3545
3780
 
3546
- if (bestMatch) {
3547
- // if there was a best match, merge the siblings in too and return the
3548
- // whole bunch
3549
- return insertSiblings(previousSibling, morphedNode, nextSibling);
3550
- } else {
3551
- // otherwise nothing was added to the DOM
3552
- return []
3781
+ return morphNormalizedContent(oldNode, normalizedContent, ctx);
3553
3782
  }
3554
- } else {
3555
- throw "Do not understand how to morph style " + ctx.morphStyle;
3556
- }
3557
- }
3558
-
3559
3783
 
3784
+ function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3785
+ if (ctx.head.block) {
3786
+ let oldHead = oldNode.querySelector('head');
3787
+ let newHead = normalizedNewContent.querySelector('head');
3788
+ if (oldHead && newHead) {
3789
+ let promises = handleHeadElement(newHead, oldHead, ctx);
3790
+ // when head promises resolve, call morph again, ignoring the head tag
3791
+ Promise.all(promises).then(function () {
3792
+ morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3793
+ head: {
3794
+ block: false,
3795
+ ignore: true
3796
+ }
3797
+ }));
3798
+ });
3799
+ return;
3800
+ }
3801
+ }
3560
3802
 
3561
- /**
3562
- * @param oldNode root node to merge content into
3563
- * @param newContent new content to merge
3564
- * @param ctx the merge context
3565
- * @returns {Element} the element that ended up in the DOM
3566
- */
3567
- function morphOldNodeTo(oldNode, newContent, ctx) {
3568
- if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3569
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return;
3570
-
3571
- oldNode.remove();
3572
- ctx.callbacks.afterNodeRemoved(oldNode);
3573
- return null;
3574
- } else if (!isSoftMatch(oldNode, newContent)) {
3575
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return;
3576
- if (ctx.callbacks.beforeNodeAdded(newContent) === false) return;
3577
-
3578
- oldNode.parentElement.replaceChild(newContent, oldNode);
3579
- ctx.callbacks.afterNodeAdded(newContent);
3580
- ctx.callbacks.afterNodeRemoved(oldNode);
3581
- return newContent;
3582
- } else {
3583
- if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return;
3803
+ if (ctx.morphStyle === "innerHTML") {
3584
3804
 
3585
- if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3586
- handleHeadElement(newContent, oldNode, ctx);
3587
- } else {
3588
- syncNodeFrom(newContent, oldNode);
3589
- morphChildren(newContent, oldNode, ctx);
3590
- }
3591
- ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3592
- return oldNode;
3593
- }
3594
- }
3805
+ // innerHTML, so we are only updating the children
3806
+ morphChildren(normalizedNewContent, oldNode, ctx);
3807
+ return oldNode.children;
3595
3808
 
3596
- /**
3597
- * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3598
- * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3599
- * by using id sets, we are able to better match up with content deeper in the DOM.
3600
- *
3601
- * Basic algorithm is, for each node in the new content:
3602
- *
3603
- * - if we have reached the end of the old parent, append the new content
3604
- * - if the new content has an id set match with the current insertion point, morph
3605
- * - search for an id set match
3606
- * - if id set match found, morph
3607
- * - otherwise search for a "soft" match
3608
- * - if a soft match is found, morph
3609
- * - otherwise, prepend the new node before the current insertion point
3610
- *
3611
- * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3612
- * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3613
- *
3614
- * @param {Element} newParent the parent element of the new content
3615
- * @param {Element } oldParent the old content that we are merging the new content into
3616
- * @param ctx the merge context
3617
- */
3618
- function morphChildren(newParent, oldParent, ctx) {
3809
+ } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3810
+ // otherwise find the best element match in the new content, morph that, and merge its siblings
3811
+ // into either side of the best match
3812
+ let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3619
3813
 
3620
- let nextNewChild = newParent.firstChild;
3621
- let insertionPoint = oldParent.firstChild;
3622
- let newChild;
3814
+ // stash the siblings that will need to be inserted on either side of the best match
3815
+ let previousSibling = bestMatch?.previousSibling;
3816
+ let nextSibling = bestMatch?.nextSibling;
3623
3817
 
3624
- // run through all the new content
3625
- while (nextNewChild) {
3818
+ // morph it
3819
+ let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3626
3820
 
3627
- newChild = nextNewChild;
3628
- nextNewChild = newChild.nextSibling;
3821
+ if (bestMatch) {
3822
+ // if there was a best match, merge the siblings in too and return the
3823
+ // whole bunch
3824
+ return insertSiblings(previousSibling, morphedNode, nextSibling);
3825
+ } else {
3826
+ // otherwise nothing was added to the DOM
3827
+ return []
3828
+ }
3829
+ } else {
3830
+ throw "Do not understand how to morph style " + ctx.morphStyle;
3831
+ }
3832
+ }
3629
3833
 
3630
- // if we are at the end of the exiting parent's children, just append
3631
- if (insertionPoint == null) {
3632
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3633
3834
 
3634
- oldParent.appendChild(newChild);
3635
- ctx.callbacks.afterNodeAdded(newChild);
3636
- removeIdsFromConsideration(ctx, newChild);
3637
- continue;
3835
+ /**
3836
+ * @param possibleActiveElement
3837
+ * @param ctx
3838
+ * @returns {boolean}
3839
+ */
3840
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
3841
+ return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
3638
3842
  }
3639
3843
 
3640
- // if the current node has an id set match then morph
3641
- if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3642
- morphOldNodeTo(insertionPoint, newChild, ctx);
3643
- insertionPoint = insertionPoint.nextSibling;
3644
- removeIdsFromConsideration(ctx, newChild);
3645
- continue;
3844
+ /**
3845
+ * @param oldNode root node to merge content into
3846
+ * @param newContent new content to merge
3847
+ * @param ctx the merge context
3848
+ * @returns {Element} the element that ended up in the DOM
3849
+ */
3850
+ function morphOldNodeTo(oldNode, newContent, ctx) {
3851
+ if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3852
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3853
+
3854
+ oldNode.remove();
3855
+ ctx.callbacks.afterNodeRemoved(oldNode);
3856
+ return null;
3857
+ } else if (!isSoftMatch(oldNode, newContent)) {
3858
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3859
+ if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
3860
+
3861
+ oldNode.parentElement.replaceChild(newContent, oldNode);
3862
+ ctx.callbacks.afterNodeAdded(newContent);
3863
+ ctx.callbacks.afterNodeRemoved(oldNode);
3864
+ return newContent;
3865
+ } else {
3866
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
3867
+
3868
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3869
+ handleHeadElement(newContent, oldNode, ctx);
3870
+ } else {
3871
+ syncNodeFrom(newContent, oldNode, ctx);
3872
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
3873
+ morphChildren(newContent, oldNode, ctx);
3874
+ }
3875
+ }
3876
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3877
+ return oldNode;
3878
+ }
3646
3879
  }
3647
3880
 
3648
- // otherwise search forward in the existing old children for an id set match
3649
- let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3881
+ /**
3882
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3883
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3884
+ * by using id sets, we are able to better match up with content deeper in the DOM.
3885
+ *
3886
+ * Basic algorithm is, for each node in the new content:
3887
+ *
3888
+ * - if we have reached the end of the old parent, append the new content
3889
+ * - if the new content has an id set match with the current insertion point, morph
3890
+ * - search for an id set match
3891
+ * - if id set match found, morph
3892
+ * - otherwise search for a "soft" match
3893
+ * - if a soft match is found, morph
3894
+ * - otherwise, prepend the new node before the current insertion point
3895
+ *
3896
+ * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3897
+ * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3898
+ *
3899
+ * @param {Element} newParent the parent element of the new content
3900
+ * @param {Element } oldParent the old content that we are merging the new content into
3901
+ * @param ctx the merge context
3902
+ */
3903
+ function morphChildren(newParent, oldParent, ctx) {
3904
+
3905
+ let nextNewChild = newParent.firstChild;
3906
+ let insertionPoint = oldParent.firstChild;
3907
+ let newChild;
3908
+
3909
+ // run through all the new content
3910
+ while (nextNewChild) {
3911
+
3912
+ newChild = nextNewChild;
3913
+ nextNewChild = newChild.nextSibling;
3914
+
3915
+ // if we are at the end of the exiting parent's children, just append
3916
+ if (insertionPoint == null) {
3917
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3918
+
3919
+ oldParent.appendChild(newChild);
3920
+ ctx.callbacks.afterNodeAdded(newChild);
3921
+ removeIdsFromConsideration(ctx, newChild);
3922
+ continue;
3923
+ }
3650
3924
 
3651
- // if we found a potential match, remove the nodes until that point and morph
3652
- if (idSetMatch) {
3653
- insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3654
- morphOldNodeTo(idSetMatch, newChild, ctx);
3655
- removeIdsFromConsideration(ctx, newChild);
3656
- continue;
3657
- }
3925
+ // if the current node has an id set match then morph
3926
+ if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3927
+ morphOldNodeTo(insertionPoint, newChild, ctx);
3928
+ insertionPoint = insertionPoint.nextSibling;
3929
+ removeIdsFromConsideration(ctx, newChild);
3930
+ continue;
3931
+ }
3658
3932
 
3659
- // no id set match found, so scan forward for a soft match for the current node
3660
- let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3933
+ // otherwise search forward in the existing old children for an id set match
3934
+ let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3661
3935
 
3662
- // if we found a soft match for the current node, morph
3663
- if (softMatch) {
3664
- insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3665
- morphOldNodeTo(softMatch, newChild, ctx);
3666
- removeIdsFromConsideration(ctx, newChild);
3667
- continue;
3668
- }
3936
+ // if we found a potential match, remove the nodes until that point and morph
3937
+ if (idSetMatch) {
3938
+ insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3939
+ morphOldNodeTo(idSetMatch, newChild, ctx);
3940
+ removeIdsFromConsideration(ctx, newChild);
3941
+ continue;
3942
+ }
3669
3943
 
3670
- // abandon all hope of morphing, just insert the new child before the insertion point
3671
- // and move on
3672
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3944
+ // no id set match found, so scan forward for a soft match for the current node
3945
+ let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3673
3946
 
3674
- oldParent.insertBefore(newChild, insertionPoint);
3675
- ctx.callbacks.afterNodeAdded(newChild);
3676
- removeIdsFromConsideration(ctx, newChild);
3677
- }
3947
+ // if we found a soft match for the current node, morph
3948
+ if (softMatch) {
3949
+ insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3950
+ morphOldNodeTo(softMatch, newChild, ctx);
3951
+ removeIdsFromConsideration(ctx, newChild);
3952
+ continue;
3953
+ }
3678
3954
 
3679
- // remove any remaining old nodes that didn't match up with new content
3680
- while (insertionPoint !== null) {
3955
+ // abandon all hope of morphing, just insert the new child before the insertion point
3956
+ // and move on
3957
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3681
3958
 
3682
- let tempNode = insertionPoint;
3683
- insertionPoint = insertionPoint.nextSibling;
3684
- removeNode(tempNode, ctx);
3685
- }
3686
- }
3959
+ oldParent.insertBefore(newChild, insertionPoint);
3960
+ ctx.callbacks.afterNodeAdded(newChild);
3961
+ removeIdsFromConsideration(ctx, newChild);
3962
+ }
3687
3963
 
3688
- //=============================================================================
3689
- // Attribute Syncing Code
3690
- //=============================================================================
3964
+ // remove any remaining old nodes that didn't match up with new content
3965
+ while (insertionPoint !== null) {
3691
3966
 
3692
- /**
3693
- * syncs a given node with another node, copying over all attributes and
3694
- * inner element state from the 'from' node to the 'to' node
3695
- *
3696
- * @param {Element} from the element to copy attributes & state from
3697
- * @param {Element} to the element to copy attributes & state to
3698
- */
3699
- function syncNodeFrom(from, to) {
3700
- let type = from.nodeType;
3701
-
3702
- // if is an element type, sync the attributes from the
3703
- // new node into the new node
3704
- if (type === 1 /* element type */) {
3705
- const fromAttributes = from.attributes;
3706
- const toAttributes = to.attributes;
3707
- for (const fromAttribute of fromAttributes) {
3708
- if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
3709
- to.setAttribute(fromAttribute.name, fromAttribute.value);
3967
+ let tempNode = insertionPoint;
3968
+ insertionPoint = insertionPoint.nextSibling;
3969
+ removeNode(tempNode, ctx);
3710
3970
  }
3711
3971
  }
3712
- for (const toAttribute of toAttributes) {
3713
- if (!from.hasAttribute(toAttribute.name)) {
3714
- to.removeAttribute(toAttribute.name);
3972
+
3973
+ //=============================================================================
3974
+ // Attribute Syncing Code
3975
+ //=============================================================================
3976
+
3977
+ /**
3978
+ * @param attr {String} the attribute to be mutated
3979
+ * @param to {Element} the element that is going to be updated
3980
+ * @param updateType {("update"|"remove")}
3981
+ * @param ctx the merge context
3982
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
3983
+ */
3984
+ function ignoreAttribute(attr, to, updateType, ctx) {
3985
+ if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
3986
+ return true;
3715
3987
  }
3988
+ return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
3716
3989
  }
3717
- }
3718
3990
 
3719
- // sync text nodes
3720
- if (type === 8 /* comment */ || type === 3 /* text */) {
3721
- if (to.nodeValue !== from.nodeValue) {
3722
- to.nodeValue = from.nodeValue;
3723
- }
3724
- }
3991
+ /**
3992
+ * syncs a given node with another node, copying over all attributes and
3993
+ * inner element state from the 'from' node to the 'to' node
3994
+ *
3995
+ * @param {Element} from the element to copy attributes & state from
3996
+ * @param {Element} to the element to copy attributes & state to
3997
+ * @param ctx the merge context
3998
+ */
3999
+ function syncNodeFrom(from, to, ctx) {
4000
+ let type = from.nodeType;
4001
+
4002
+ // if is an element type, sync the attributes from the
4003
+ // new node into the new node
4004
+ if (type === 1 /* element type */) {
4005
+ const fromAttributes = from.attributes;
4006
+ const toAttributes = to.attributes;
4007
+ for (const fromAttribute of fromAttributes) {
4008
+ if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
4009
+ continue;
4010
+ }
4011
+ if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
4012
+ to.setAttribute(fromAttribute.name, fromAttribute.value);
4013
+ }
4014
+ }
4015
+ // iterate backwards to avoid skipping over items when a delete occurs
4016
+ for (let i = toAttributes.length - 1; 0 <= i; i--) {
4017
+ const toAttribute = toAttributes[i];
4018
+ if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
4019
+ continue;
4020
+ }
4021
+ if (!from.hasAttribute(toAttribute.name)) {
4022
+ to.removeAttribute(toAttribute.name);
4023
+ }
4024
+ }
4025
+ }
3725
4026
 
3726
- // NB: many bothans died to bring us information:
3727
- //
3728
- // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
3729
- // https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
3730
-
3731
- // sync input value
3732
- if (from instanceof HTMLInputElement &&
3733
- to instanceof HTMLInputElement &&
3734
- from.type !== 'file') {
3735
-
3736
- to.value = from.value || '';
3737
- syncAttribute(from, to, 'value');
3738
-
3739
- // sync boolean attributes
3740
- syncAttribute(from, to, 'checked');
3741
- syncAttribute(from, to, 'disabled');
3742
- } else if (from instanceof HTMLOptionElement) {
3743
- syncAttribute(from, to, 'selected');
3744
- } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
3745
- let fromValue = from.value;
3746
- let toValue = to.value;
3747
- if (fromValue !== toValue) {
3748
- to.value = fromValue;
3749
- }
3750
- if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
3751
- to.firstChild.nodeValue = fromValue;
3752
- }
3753
- }
3754
- }
4027
+ // sync text nodes
4028
+ if (type === 8 /* comment */ || type === 3 /* text */) {
4029
+ if (to.nodeValue !== from.nodeValue) {
4030
+ to.nodeValue = from.nodeValue;
4031
+ }
4032
+ }
3755
4033
 
3756
- function syncAttribute(from, to, attributeName) {
3757
- if (from[attributeName] !== to[attributeName]) {
3758
- if (from[attributeName]) {
3759
- to.setAttribute(attributeName, from[attributeName]);
3760
- } else {
3761
- to.removeAttribute(attributeName);
4034
+ if (!ignoreValueOfActiveElement(to, ctx)) {
4035
+ // sync input values
4036
+ syncInputValue(from, to, ctx);
4037
+ }
3762
4038
  }
3763
- }
3764
- }
3765
4039
 
3766
- //=============================================================================
3767
- // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
3768
- //=============================================================================
3769
- function handleHeadElement(newHeadTag, currentHead, ctx) {
4040
+ /**
4041
+ * @param from {Element} element to sync the value from
4042
+ * @param to {Element} element to sync the value to
4043
+ * @param attributeName {String} the attribute name
4044
+ * @param ctx the merge context
4045
+ */
4046
+ function syncBooleanAttribute(from, to, attributeName, ctx) {
4047
+ if (from[attributeName] !== to[attributeName]) {
4048
+ let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
4049
+ if (!ignoreUpdate) {
4050
+ to[attributeName] = from[attributeName];
4051
+ }
4052
+ if (from[attributeName]) {
4053
+ if (!ignoreUpdate) {
4054
+ to.setAttribute(attributeName, from[attributeName]);
4055
+ }
4056
+ } else {
4057
+ if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
4058
+ to.removeAttribute(attributeName);
4059
+ }
4060
+ }
4061
+ }
4062
+ }
3770
4063
 
3771
- let added = [];
3772
- let removed = [];
3773
- let preserved = [];
3774
- let nodesToAppend = [];
4064
+ /**
4065
+ * NB: many bothans died to bring us information:
4066
+ *
4067
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
4068
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
4069
+ *
4070
+ * @param from {Element} the element to sync the input value from
4071
+ * @param to {Element} the element to sync the input value to
4072
+ * @param ctx the merge context
4073
+ */
4074
+ function syncInputValue(from, to, ctx) {
4075
+ if (from instanceof HTMLInputElement &&
4076
+ to instanceof HTMLInputElement &&
4077
+ from.type !== 'file') {
4078
+
4079
+ let fromValue = from.value;
4080
+ let toValue = to.value;
4081
+
4082
+ // sync boolean attributes
4083
+ syncBooleanAttribute(from, to, 'checked', ctx);
4084
+ syncBooleanAttribute(from, to, 'disabled', ctx);
4085
+
4086
+ if (!from.hasAttribute('value')) {
4087
+ if (!ignoreAttribute('value', to, 'remove', ctx)) {
4088
+ to.value = '';
4089
+ to.removeAttribute('value');
4090
+ }
4091
+ } else if (fromValue !== toValue) {
4092
+ if (!ignoreAttribute('value', to, 'update', ctx)) {
4093
+ to.setAttribute('value', fromValue);
4094
+ to.value = fromValue;
4095
+ }
4096
+ }
4097
+ } else if (from instanceof HTMLOptionElement) {
4098
+ syncBooleanAttribute(from, to, 'selected', ctx);
4099
+ } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
4100
+ let fromValue = from.value;
4101
+ let toValue = to.value;
4102
+ if (ignoreAttribute('value', to, 'update', ctx)) {
4103
+ return;
4104
+ }
4105
+ if (fromValue !== toValue) {
4106
+ to.value = fromValue;
4107
+ }
4108
+ if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
4109
+ to.firstChild.nodeValue = fromValue;
4110
+ }
4111
+ }
4112
+ }
3775
4113
 
3776
- let headMergeStyle = ctx.head.style;
4114
+ //=============================================================================
4115
+ // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
4116
+ //=============================================================================
4117
+ function handleHeadElement(newHeadTag, currentHead, ctx) {
3777
4118
 
3778
- // put all new head elements into a Map, by their outerHTML
3779
- let srcToNewHeadNodes = new Map();
3780
- for (const newHeadChild of newHeadTag.children) {
3781
- srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3782
- }
4119
+ let added = [];
4120
+ let removed = [];
4121
+ let preserved = [];
4122
+ let nodesToAppend = [];
3783
4123
 
3784
- // for each elt in the current head
3785
- for (const currentHeadElt of currentHead.children) {
4124
+ let headMergeStyle = ctx.head.style;
3786
4125
 
3787
- // If the current head element is in the map
3788
- let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
3789
- let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
3790
- let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
3791
- if (inNewContent || isPreserved) {
3792
- if (isReAppended) {
3793
- // remove the current version and let the new version replace it and re-execute
3794
- removed.push(currentHeadElt);
3795
- } else {
3796
- // this element already exists and should not be re-appended, so remove it from
3797
- // the new content map, preserving it in the DOM
3798
- srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
3799
- preserved.push(currentHeadElt);
4126
+ // put all new head elements into a Map, by their outerHTML
4127
+ let srcToNewHeadNodes = new Map();
4128
+ for (const newHeadChild of newHeadTag.children) {
4129
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3800
4130
  }
3801
- } else {
3802
- if (headMergeStyle === "append") {
3803
- // we are appending and this existing element is not new content
3804
- // so if and only if it is marked for re-append do we do anything
3805
- if (isReAppended) {
3806
- removed.push(currentHeadElt);
3807
- nodesToAppend.push(currentHeadElt);
4131
+
4132
+ // for each elt in the current head
4133
+ for (const currentHeadElt of currentHead.children) {
4134
+
4135
+ // If the current head element is in the map
4136
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
4137
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
4138
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
4139
+ if (inNewContent || isPreserved) {
4140
+ if (isReAppended) {
4141
+ // remove the current version and let the new version replace it and re-execute
4142
+ removed.push(currentHeadElt);
4143
+ } else {
4144
+ // this element already exists and should not be re-appended, so remove it from
4145
+ // the new content map, preserving it in the DOM
4146
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
4147
+ preserved.push(currentHeadElt);
4148
+ }
4149
+ } else {
4150
+ if (headMergeStyle === "append") {
4151
+ // we are appending and this existing element is not new content
4152
+ // so if and only if it is marked for re-append do we do anything
4153
+ if (isReAppended) {
4154
+ removed.push(currentHeadElt);
4155
+ nodesToAppend.push(currentHeadElt);
4156
+ }
4157
+ } else {
4158
+ // if this is a merge, we remove this content since it is not in the new head
4159
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
4160
+ removed.push(currentHeadElt);
4161
+ }
4162
+ }
3808
4163
  }
3809
- } else {
3810
- // if this is a merge, we remove this content since it is not in the new head
3811
- if (ctx.head.shouldRemove(currentHeadElt) !== false) {
3812
- removed.push(currentHeadElt);
4164
+ }
4165
+
4166
+ // Push the remaining new head elements in the Map into the
4167
+ // nodes to append to the head tag
4168
+ nodesToAppend.push(...srcToNewHeadNodes.values());
4169
+
4170
+ let promises = [];
4171
+ for (const newNode of nodesToAppend) {
4172
+ let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
4173
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
4174
+ if (newElt.href || newElt.src) {
4175
+ let resolve = null;
4176
+ let promise = new Promise(function (_resolve) {
4177
+ resolve = _resolve;
4178
+ });
4179
+ newElt.addEventListener('load', function () {
4180
+ resolve();
4181
+ });
4182
+ promises.push(promise);
4183
+ }
4184
+ currentHead.appendChild(newElt);
4185
+ ctx.callbacks.afterNodeAdded(newElt);
4186
+ added.push(newElt);
3813
4187
  }
3814
4188
  }
3815
- }
3816
- }
3817
4189
 
3818
- // Push the remaining new head elements in the Map into the
3819
- // nodes to append to the head tag
3820
- nodesToAppend.push(...srcToNewHeadNodes.values());
3821
-
3822
- let promises = [];
3823
- for (const newNode of nodesToAppend) {
3824
- let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
3825
- if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
3826
- if (newElt.href || newElt.src) {
3827
- let resolve = null;
3828
- let promise = new Promise(function (_resolve) {
3829
- resolve = _resolve;
3830
- });
3831
- newElt.addEventListener('load',function() {
3832
- resolve();
3833
- });
3834
- promises.push(promise);
4190
+ // remove all removed elements, after we have appended the new elements to avoid
4191
+ // additional network requests for things like style sheets
4192
+ for (const removedElement of removed) {
4193
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
4194
+ currentHead.removeChild(removedElement);
4195
+ ctx.callbacks.afterNodeRemoved(removedElement);
4196
+ }
3835
4197
  }
3836
- currentHead.appendChild(newElt);
3837
- ctx.callbacks.afterNodeAdded(newElt);
3838
- added.push(newElt);
4198
+
4199
+ ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
4200
+ return promises;
3839
4201
  }
3840
- }
3841
4202
 
3842
- // remove all removed elements, after we have appended the new elements to avoid
3843
- // additional network requests for things like style sheets
3844
- for (const removedElement of removed) {
3845
- if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
3846
- currentHead.removeChild(removedElement);
3847
- ctx.callbacks.afterNodeRemoved(removedElement);
4203
+ function noOp() {
3848
4204
  }
3849
- }
3850
4205
 
3851
- ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
3852
- return promises;
3853
- }
4206
+ /*
4207
+ Deep merges the config object and the Idiomoroph.defaults object to
4208
+ produce a final configuration object
4209
+ */
4210
+ function mergeDefaults(config) {
4211
+ let finalConfig = {};
4212
+ // copy top level stuff into final config
4213
+ Object.assign(finalConfig, defaults);
4214
+ Object.assign(finalConfig, config);
4215
+
4216
+ // copy callbacks into final config (do this to deep merge the callbacks)
4217
+ finalConfig.callbacks = {};
4218
+ Object.assign(finalConfig.callbacks, defaults.callbacks);
4219
+ Object.assign(finalConfig.callbacks, config.callbacks);
4220
+
4221
+ // copy head config into final config (do this to deep merge the head)
4222
+ finalConfig.head = {};
4223
+ Object.assign(finalConfig.head, defaults.head);
4224
+ Object.assign(finalConfig.head, config.head);
4225
+ return finalConfig;
4226
+ }
3854
4227
 
3855
- function noOp() {}
4228
+ function createMorphContext(oldNode, newContent, config) {
4229
+ config = mergeDefaults(config);
4230
+ return {
4231
+ target: oldNode,
4232
+ newContent: newContent,
4233
+ config: config,
4234
+ morphStyle: config.morphStyle,
4235
+ ignoreActive: config.ignoreActive,
4236
+ ignoreActiveValue: config.ignoreActiveValue,
4237
+ idMap: createIdMap(oldNode, newContent),
4238
+ deadIds: new Set(),
4239
+ callbacks: config.callbacks,
4240
+ head: config.head
4241
+ }
4242
+ }
3856
4243
 
3857
- function createMorphContext(oldNode, newContent, config) {
3858
- return {
3859
- target:oldNode,
3860
- newContent: newContent,
3861
- config: config,
3862
- morphStyle : config.morphStyle,
3863
- ignoreActive : config.ignoreActive,
3864
- idMap: createIdMap(oldNode, newContent),
3865
- deadIds: new Set(),
3866
- callbacks: Object.assign({
3867
- beforeNodeAdded: noOp,
3868
- afterNodeAdded : noOp,
3869
- beforeNodeMorphed: noOp,
3870
- afterNodeMorphed : noOp,
3871
- beforeNodeRemoved: noOp,
3872
- afterNodeRemoved : noOp,
3873
-
3874
- }, config.callbacks),
3875
- head: Object.assign({
3876
- style: 'merge',
3877
- shouldPreserve : function(elt) {
3878
- return elt.getAttribute("im-preserve") === "true";
3879
- },
3880
- shouldReAppend : function(elt) {
3881
- return elt.getAttribute("im-re-append") === "true";
3882
- },
3883
- shouldRemove : noOp,
3884
- afterHeadMorphed : noOp,
3885
- }, config.head),
3886
- }
3887
- }
4244
+ function isIdSetMatch(node1, node2, ctx) {
4245
+ if (node1 == null || node2 == null) {
4246
+ return false;
4247
+ }
4248
+ if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
4249
+ if (node1.id !== "" && node1.id === node2.id) {
4250
+ return true;
4251
+ } else {
4252
+ return getIdIntersectionCount(ctx, node1, node2) > 0;
4253
+ }
4254
+ }
4255
+ return false;
4256
+ }
3888
4257
 
3889
- function isIdSetMatch(node1, node2, ctx) {
3890
- if (node1 == null || node2 == null) {
3891
- return false;
3892
- }
3893
- if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
3894
- if (node1.id !== "" && node1.id === node2.id) {
3895
- return true;
3896
- } else {
3897
- return getIdIntersectionCount(ctx, node1, node2) > 0;
4258
+ function isSoftMatch(node1, node2) {
4259
+ if (node1 == null || node2 == null) {
4260
+ return false;
4261
+ }
4262
+ return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
3898
4263
  }
3899
- }
3900
- return false;
3901
- }
3902
4264
 
3903
- function isSoftMatch(node1, node2) {
3904
- if (node1 == null || node2 == null) {
3905
- return false;
3906
- }
3907
- return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
3908
- }
4265
+ function removeNodesBetween(startInclusive, endExclusive, ctx) {
4266
+ while (startInclusive !== endExclusive) {
4267
+ let tempNode = startInclusive;
4268
+ startInclusive = startInclusive.nextSibling;
4269
+ removeNode(tempNode, ctx);
4270
+ }
4271
+ removeIdsFromConsideration(ctx, endExclusive);
4272
+ return endExclusive.nextSibling;
4273
+ }
3909
4274
 
3910
- function removeNodesBetween(startInclusive, endExclusive, ctx) {
3911
- while (startInclusive !== endExclusive) {
3912
- let tempNode = startInclusive;
3913
- startInclusive = startInclusive.nextSibling;
3914
- removeNode(tempNode, ctx);
3915
- }
3916
- removeIdsFromConsideration(ctx, endExclusive);
3917
- return endExclusive.nextSibling;
3918
- }
4275
+ //=============================================================================
4276
+ // Scans forward from the insertionPoint in the old parent looking for a potential id match
4277
+ // for the newChild. We stop if we find a potential id match for the new child OR
4278
+ // if the number of potential id matches we are discarding is greater than the
4279
+ // potential id matches for the new child
4280
+ //=============================================================================
4281
+ function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4282
+
4283
+ // max id matches we are willing to discard in our search
4284
+ let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4285
+
4286
+ let potentialMatch = null;
4287
+
4288
+ // only search forward if there is a possibility of an id match
4289
+ if (newChildPotentialIdCount > 0) {
4290
+ let potentialMatch = insertionPoint;
4291
+ // if there is a possibility of an id match, scan forward
4292
+ // keep track of the potential id match count we are discarding (the
4293
+ // newChildPotentialIdCount must be greater than this to make it likely
4294
+ // worth it)
4295
+ let otherMatchCount = 0;
4296
+ while (potentialMatch != null) {
4297
+
4298
+ // If we have an id match, return the current potential match
4299
+ if (isIdSetMatch(newChild, potentialMatch, ctx)) {
4300
+ return potentialMatch;
4301
+ }
3919
4302
 
3920
- //=============================================================================
3921
- // Scans forward from the insertionPoint in the old parent looking for a potential id match
3922
- // for the newChild. We stop if we find a potential id match for the new child OR
3923
- // if the number of potential id matches we are discarding is greater than the
3924
- // potential id matches for the new child
3925
- //=============================================================================
3926
- function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4303
+ // computer the other potential matches of this new content
4304
+ otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
4305
+ if (otherMatchCount > newChildPotentialIdCount) {
4306
+ // if we have more potential id matches in _other_ content, we
4307
+ // do not have a good candidate for an id match, so return null
4308
+ return null;
4309
+ }
3927
4310
 
3928
- // max id matches we are willing to discard in our search
3929
- let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4311
+ // advanced to the next old content child
4312
+ potentialMatch = potentialMatch.nextSibling;
4313
+ }
4314
+ }
4315
+ return potentialMatch;
4316
+ }
3930
4317
 
3931
- let potentialMatch = null;
4318
+ //=============================================================================
4319
+ // Scans forward from the insertionPoint in the old parent looking for a potential soft match
4320
+ // for the newChild. We stop if we find a potential soft match for the new child OR
4321
+ // if we find a potential id match in the old parents children OR if we find two
4322
+ // potential soft matches for the next two pieces of new content
4323
+ //=============================================================================
4324
+ function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
3932
4325
 
3933
- // only search forward if there is a possibility of an id match
3934
- if (newChildPotentialIdCount > 0) {
3935
- let potentialMatch = insertionPoint;
3936
- // if there is a possibility of an id match, scan forward
3937
- // keep track of the potential id match count we are discarding (the
3938
- // newChildPotentialIdCount must be greater than this to make it likely
3939
- // worth it)
3940
- let otherMatchCount = 0;
3941
- while (potentialMatch != null) {
4326
+ let potentialSoftMatch = insertionPoint;
4327
+ let nextSibling = newChild.nextSibling;
4328
+ let siblingSoftMatchCount = 0;
3942
4329
 
3943
- // If we have an id match, return the current potential match
3944
- if (isIdSetMatch(newChild, potentialMatch, ctx)) {
3945
- return potentialMatch;
3946
- }
4330
+ while (potentialSoftMatch != null) {
3947
4331
 
3948
- // computer the other potential matches of this new content
3949
- otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
3950
- if (otherMatchCount > newChildPotentialIdCount) {
3951
- // if we have more potential id matches in _other_ content, we
3952
- // do not have a good candidate for an id match, so return null
3953
- return null;
3954
- }
3955
-
3956
- // advanced to the next old content child
3957
- potentialMatch = potentialMatch.nextSibling;
3958
- }
3959
- }
3960
- return potentialMatch;
3961
- }
4332
+ if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
4333
+ // the current potential soft match has a potential id set match with the remaining new
4334
+ // content so bail out of looking
4335
+ return null;
4336
+ }
3962
4337
 
3963
- //=============================================================================
3964
- // Scans forward from the insertionPoint in the old parent looking for a potential soft match
3965
- // for the newChild. We stop if we find a potential soft match for the new child OR
3966
- // if we find a potential id match in the old parents children OR if we find two
3967
- // potential soft matches for the next two pieces of new content
3968
- //=============================================================================
3969
- function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4338
+ // if we have a soft match with the current node, return it
4339
+ if (isSoftMatch(newChild, potentialSoftMatch)) {
4340
+ return potentialSoftMatch;
4341
+ }
3970
4342
 
3971
- let potentialSoftMatch = insertionPoint;
3972
- let nextSibling = newChild.nextSibling;
3973
- let siblingSoftMatchCount = 0;
4343
+ if (isSoftMatch(nextSibling, potentialSoftMatch)) {
4344
+ // the next new node has a soft match with this node, so
4345
+ // increment the count of future soft matches
4346
+ siblingSoftMatchCount++;
4347
+ nextSibling = nextSibling.nextSibling;
3974
4348
 
3975
- while (potentialSoftMatch != null) {
4349
+ // If there are two future soft matches, bail to allow the siblings to soft match
4350
+ // so that we don't consume future soft matches for the sake of the current node
4351
+ if (siblingSoftMatchCount >= 2) {
4352
+ return null;
4353
+ }
4354
+ }
3976
4355
 
3977
- if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
3978
- // the current potential soft match has a potential id set match with the remaining new
3979
- // content so bail out of looking
3980
- return null;
3981
- }
4356
+ // advanced to the next old content child
4357
+ potentialSoftMatch = potentialSoftMatch.nextSibling;
4358
+ }
3982
4359
 
3983
- // if we have a soft match with the current node, return it
3984
- if (isSoftMatch(newChild, potentialSoftMatch)) {
3985
4360
  return potentialSoftMatch;
3986
4361
  }
3987
4362
 
3988
- if (isSoftMatch(nextSibling, potentialSoftMatch)) {
3989
- // the next new node has a soft match with this node, so
3990
- // increment the count of future soft matches
3991
- siblingSoftMatchCount++;
3992
- nextSibling = nextSibling.nextSibling;
3993
-
3994
- // If there are two future soft matches, bail to allow the siblings to soft match
3995
- // so that we don't consume future soft matches for the sake of the current node
3996
- if (siblingSoftMatchCount >= 2) {
3997
- return null;
4363
+ function parseContent(newContent) {
4364
+ let parser = new DOMParser();
4365
+
4366
+ // remove svgs to avoid false-positive matches on head, etc.
4367
+ let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
4368
+
4369
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
4370
+ if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
4371
+ let content = parser.parseFromString(newContent, "text/html");
4372
+ // if it is a full HTML document, return the document itself as the parent container
4373
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
4374
+ content.generatedByIdiomorph = true;
4375
+ return content;
4376
+ } else {
4377
+ // otherwise return the html element as the parent container
4378
+ let htmlElement = content.firstChild;
4379
+ if (htmlElement) {
4380
+ htmlElement.generatedByIdiomorph = true;
4381
+ return htmlElement;
4382
+ } else {
4383
+ return null;
4384
+ }
4385
+ }
4386
+ } else {
4387
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
4388
+ // deal with touchy tags like tr, tbody, etc.
4389
+ let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
4390
+ let content = responseDoc.body.querySelector('template').content;
4391
+ content.generatedByIdiomorph = true;
4392
+ return content
3998
4393
  }
3999
4394
  }
4000
4395
 
4001
- // advanced to the next old content child
4002
- potentialSoftMatch = potentialSoftMatch.nextSibling;
4003
- }
4004
-
4005
- return potentialSoftMatch;
4006
- }
4007
-
4008
- function parseContent(newContent) {
4009
- let parser = new DOMParser();
4010
-
4011
- // remove svgs to avoid false-positive matches on head, etc.
4012
- let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
4013
-
4014
- // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
4015
- if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
4016
- let content = parser.parseFromString(newContent, "text/html");
4017
- // if it is a full HTML document, return the document itself as the parent container
4018
- if (contentWithSvgsRemoved.match(/<\/html>/)) {
4019
- content.generatedByIdiomorph = true;
4020
- return content;
4021
- } else {
4022
- // otherwise return the html element as the parent container
4023
- let htmlElement = content.firstChild;
4024
- if (htmlElement) {
4025
- htmlElement.generatedByIdiomorph = true;
4026
- return htmlElement;
4396
+ function normalizeContent(newContent) {
4397
+ if (newContent == null) {
4398
+ // noinspection UnnecessaryLocalVariableJS
4399
+ const dummyParent = document.createElement('div');
4400
+ return dummyParent;
4401
+ } else if (newContent.generatedByIdiomorph) {
4402
+ // the template tag created by idiomorph parsing can serve as a dummy parent
4403
+ return newContent;
4404
+ } else if (newContent instanceof Node) {
4405
+ // a single node is added as a child to a dummy parent
4406
+ const dummyParent = document.createElement('div');
4407
+ dummyParent.append(newContent);
4408
+ return dummyParent;
4027
4409
  } else {
4028
- return null;
4410
+ // all nodes in the array or HTMLElement collection are consolidated under
4411
+ // a single dummy parent element
4412
+ const dummyParent = document.createElement('div');
4413
+ for (const elt of [...newContent]) {
4414
+ dummyParent.append(elt);
4415
+ }
4416
+ return dummyParent;
4029
4417
  }
4030
4418
  }
4031
- } else {
4032
- // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
4033
- // deal with touchy tags like tr, tbody, etc.
4034
- let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
4035
- let content = responseDoc.body.querySelector('template').content;
4036
- content.generatedByIdiomorph = true;
4037
- return content
4038
- }
4039
- }
4040
-
4041
- function normalizeContent(newContent) {
4042
- if (newContent == null) {
4043
- // noinspection UnnecessaryLocalVariableJS
4044
- const dummyParent = document.createElement('div');
4045
- return dummyParent;
4046
- } else if (newContent.generatedByIdiomorph) {
4047
- // the template tag created by idiomorph parsing can serve as a dummy parent
4048
- return newContent;
4049
- } else if (newContent instanceof Node) {
4050
- // a single node is added as a child to a dummy parent
4051
- const dummyParent = document.createElement('div');
4052
- dummyParent.append(newContent);
4053
- return dummyParent;
4054
- } else {
4055
- // all nodes in the array or HTMLElement collection are consolidated under
4056
- // a single dummy parent element
4057
- const dummyParent = document.createElement('div');
4058
- for (const elt of [...newContent]) {
4059
- dummyParent.append(elt);
4060
- }
4061
- return dummyParent;
4062
- }
4063
- }
4064
4419
 
4065
- function insertSiblings(previousSibling, morphedNode, nextSibling) {
4066
- let stack = [];
4067
- let added = [];
4068
- while (previousSibling != null) {
4069
- stack.push(previousSibling);
4070
- previousSibling = previousSibling.previousSibling;
4071
- }
4072
- while (stack.length > 0) {
4073
- let node = stack.pop();
4074
- added.push(node); // push added preceding siblings on in order and insert
4075
- morphedNode.parentElement.insertBefore(node, morphedNode);
4076
- }
4077
- added.push(morphedNode);
4078
- while (nextSibling != null) {
4079
- stack.push(nextSibling);
4080
- added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4081
- nextSibling = nextSibling.nextSibling;
4082
- }
4083
- while (stack.length > 0) {
4084
- morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4085
- }
4086
- return added;
4087
- }
4420
+ function insertSiblings(previousSibling, morphedNode, nextSibling) {
4421
+ let stack = [];
4422
+ let added = [];
4423
+ while (previousSibling != null) {
4424
+ stack.push(previousSibling);
4425
+ previousSibling = previousSibling.previousSibling;
4426
+ }
4427
+ while (stack.length > 0) {
4428
+ let node = stack.pop();
4429
+ added.push(node); // push added preceding siblings on in order and insert
4430
+ morphedNode.parentElement.insertBefore(node, morphedNode);
4431
+ }
4432
+ added.push(morphedNode);
4433
+ while (nextSibling != null) {
4434
+ stack.push(nextSibling);
4435
+ added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4436
+ nextSibling = nextSibling.nextSibling;
4437
+ }
4438
+ while (stack.length > 0) {
4439
+ morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4440
+ }
4441
+ return added;
4442
+ }
4088
4443
 
4089
- function findBestNodeMatch(newContent, oldNode, ctx) {
4090
- let currentElement;
4091
- currentElement = newContent.firstChild;
4092
- let bestElement = currentElement;
4093
- let score = 0;
4094
- while (currentElement) {
4095
- let newScore = scoreElement(currentElement, oldNode, ctx);
4096
- if (newScore > score) {
4097
- bestElement = currentElement;
4098
- score = newScore;
4444
+ function findBestNodeMatch(newContent, oldNode, ctx) {
4445
+ let currentElement;
4446
+ currentElement = newContent.firstChild;
4447
+ let bestElement = currentElement;
4448
+ let score = 0;
4449
+ while (currentElement) {
4450
+ let newScore = scoreElement(currentElement, oldNode, ctx);
4451
+ if (newScore > score) {
4452
+ bestElement = currentElement;
4453
+ score = newScore;
4454
+ }
4455
+ currentElement = currentElement.nextSibling;
4456
+ }
4457
+ return bestElement;
4099
4458
  }
4100
- currentElement = currentElement.nextSibling;
4101
- }
4102
- return bestElement;
4103
- }
4104
4459
 
4105
- function scoreElement(node1, node2, ctx) {
4106
- if (isSoftMatch(node1, node2)) {
4107
- return .5 + getIdIntersectionCount(ctx, node1, node2);
4108
- }
4109
- return 0;
4110
- }
4460
+ function scoreElement(node1, node2, ctx) {
4461
+ if (isSoftMatch(node1, node2)) {
4462
+ return .5 + getIdIntersectionCount(ctx, node1, node2);
4463
+ }
4464
+ return 0;
4465
+ }
4111
4466
 
4112
- function removeNode(tempNode, ctx) {
4113
- removeIdsFromConsideration(ctx, tempNode);
4114
- if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4467
+ function removeNode(tempNode, ctx) {
4468
+ removeIdsFromConsideration(ctx, tempNode);
4469
+ if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4115
4470
 
4116
- tempNode.remove();
4117
- ctx.callbacks.afterNodeRemoved(tempNode);
4118
- }
4471
+ tempNode.remove();
4472
+ ctx.callbacks.afterNodeRemoved(tempNode);
4473
+ }
4119
4474
 
4120
- //=============================================================================
4121
- // ID Set Functions
4122
- //=============================================================================
4475
+ //=============================================================================
4476
+ // ID Set Functions
4477
+ //=============================================================================
4123
4478
 
4124
- function isIdInConsideration(ctx, id) {
4125
- return !ctx.deadIds.has(id);
4126
- }
4479
+ function isIdInConsideration(ctx, id) {
4480
+ return !ctx.deadIds.has(id);
4481
+ }
4127
4482
 
4128
- function idIsWithinNode(ctx, id, targetNode) {
4129
- let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4130
- return idSet.has(id);
4131
- }
4483
+ function idIsWithinNode(ctx, id, targetNode) {
4484
+ let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4485
+ return idSet.has(id);
4486
+ }
4132
4487
 
4133
- function removeIdsFromConsideration(ctx, node) {
4134
- let idSet = ctx.idMap.get(node) || EMPTY_SET;
4135
- for (const id of idSet) {
4136
- ctx.deadIds.add(id);
4137
- }
4138
- }
4488
+ function removeIdsFromConsideration(ctx, node) {
4489
+ let idSet = ctx.idMap.get(node) || EMPTY_SET;
4490
+ for (const id of idSet) {
4491
+ ctx.deadIds.add(id);
4492
+ }
4493
+ }
4139
4494
 
4140
- function getIdIntersectionCount(ctx, node1, node2) {
4141
- let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4142
- let matchCount = 0;
4143
- for (const id of sourceSet) {
4144
- // a potential match is an id in the source and potentialIdsSet, but
4145
- // that has not already been merged into the DOM
4146
- if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4147
- ++matchCount;
4495
+ function getIdIntersectionCount(ctx, node1, node2) {
4496
+ let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4497
+ let matchCount = 0;
4498
+ for (const id of sourceSet) {
4499
+ // a potential match is an id in the source and potentialIdsSet, but
4500
+ // that has not already been merged into the DOM
4501
+ if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4502
+ ++matchCount;
4503
+ }
4504
+ }
4505
+ return matchCount;
4148
4506
  }
4149
- }
4150
- return matchCount;
4151
- }
4152
4507
 
4153
- /**
4154
- * A bottom up algorithm that finds all elements with ids inside of the node
4155
- * argument and populates id sets for those nodes and all their parents, generating
4156
- * a set of ids contained within all nodes for the entire hierarchy in the DOM
4157
- *
4158
- * @param node {Element}
4159
- * @param {Map<Node, Set<String>>} idMap
4160
- */
4161
- function populateIdMapForNode(node, idMap) {
4162
- let nodeParent = node.parentElement;
4163
- // find all elements with an id property
4164
- let idElements = node.querySelectorAll('[id]');
4165
- for (const elt of idElements) {
4166
- let current = elt;
4167
- // walk up the parent hierarchy of that element, adding the id
4168
- // of element to the parent's id set
4169
- while (current !== nodeParent && current != null) {
4170
- let idSet = idMap.get(current);
4171
- // if the id set doesn't exist, create it and insert it in the map
4172
- if (idSet == null) {
4173
- idSet = new Set();
4174
- idMap.set(current, idSet);
4508
+ /**
4509
+ * A bottom up algorithm that finds all elements with ids inside of the node
4510
+ * argument and populates id sets for those nodes and all their parents, generating
4511
+ * a set of ids contained within all nodes for the entire hierarchy in the DOM
4512
+ *
4513
+ * @param node {Element}
4514
+ * @param {Map<Node, Set<String>>} idMap
4515
+ */
4516
+ function populateIdMapForNode(node, idMap) {
4517
+ let nodeParent = node.parentElement;
4518
+ // find all elements with an id property
4519
+ let idElements = node.querySelectorAll('[id]');
4520
+ for (const elt of idElements) {
4521
+ let current = elt;
4522
+ // walk up the parent hierarchy of that element, adding the id
4523
+ // of element to the parent's id set
4524
+ while (current !== nodeParent && current != null) {
4525
+ let idSet = idMap.get(current);
4526
+ // if the id set doesn't exist, create it and insert it in the map
4527
+ if (idSet == null) {
4528
+ idSet = new Set();
4529
+ idMap.set(current, idSet);
4530
+ }
4531
+ idSet.add(elt.id);
4532
+ current = current.parentElement;
4533
+ }
4175
4534
  }
4176
- idSet.add(elt.id);
4177
- current = current.parentElement;
4178
4535
  }
4179
- }
4180
- }
4181
4536
 
4182
- /**
4183
- * This function computes a map of nodes to all ids contained within that node (inclusive of the
4184
- * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4185
- * for a looser definition of "matching" than tradition id matching, and allows child nodes
4186
- * to contribute to a parent nodes matching.
4187
- *
4188
- * @param {Element} oldContent the old content that will be morphed
4189
- * @param {Element} newContent the new content to morph to
4190
- * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4191
- */
4192
- function createIdMap(oldContent, newContent) {
4193
- let idMap = new Map();
4194
- populateIdMapForNode(oldContent, idMap);
4195
- populateIdMapForNode(newContent, idMap);
4196
- return idMap;
4197
- }
4537
+ /**
4538
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
4539
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4540
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
4541
+ * to contribute to a parent nodes matching.
4542
+ *
4543
+ * @param {Element} oldContent the old content that will be morphed
4544
+ * @param {Element} newContent the new content to morph to
4545
+ * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4546
+ */
4547
+ function createIdMap(oldContent, newContent) {
4548
+ let idMap = new Map();
4549
+ populateIdMapForNode(oldContent, idMap);
4550
+ populateIdMapForNode(newContent, idMap);
4551
+ return idMap;
4552
+ }
4198
4553
 
4199
- //=============================================================================
4200
- // This is what ends up becoming the Idiomorph export
4201
- //=============================================================================
4202
- var idiomorph = { morph };
4554
+ //=============================================================================
4555
+ // This is what ends up becoming the Idiomorph global object
4556
+ //=============================================================================
4557
+ return {
4558
+ morph,
4559
+ defaults
4560
+ }
4561
+ })();
4203
4562
 
4204
4563
  class MorphRenderer extends Renderer {
4205
4564
  async render() {
@@ -4227,7 +4586,7 @@ Copyright © 2023 37signals LLC
4227
4586
  #morphElements(currentElement, newElement, morphStyle = "outerHTML") {
4228
4587
  this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement);
4229
4588
 
4230
- idiomorph.morph(currentElement, newElement, {
4589
+ Idiomorph.morph(currentElement, newElement, {
4231
4590
  morphStyle: morphStyle,
4232
4591
  callbacks: {
4233
4592
  beforeNodeAdded: this.#shouldAddElement,
@@ -4359,8 +4718,13 @@ Copyright © 2023 37signals LLC
4359
4718
  const mergedHeadElements = this.mergeProvisionalElements();
4360
4719
  const newStylesheetElements = this.copyNewHeadStylesheetElements();
4361
4720
  this.copyNewHeadScriptElements();
4721
+
4362
4722
  await mergedHeadElements;
4363
4723
  await newStylesheetElements;
4724
+
4725
+ if (this.willRender) {
4726
+ this.removeUnusedHeadStylesheetElements();
4727
+ }
4364
4728
  }
4365
4729
 
4366
4730
  async replaceBody() {
@@ -4392,6 +4756,12 @@ Copyright © 2023 37signals LLC
4392
4756
  }
4393
4757
  }
4394
4758
 
4759
+ removeUnusedHeadStylesheetElements() {
4760
+ for (const element of this.unusedHeadStylesheetElements) {
4761
+ document.head.removeChild(element);
4762
+ }
4763
+ }
4764
+
4395
4765
  async mergeProvisionalElements() {
4396
4766
  const newHeadElements = [...this.newHeadProvisionalElements];
4397
4767
 
@@ -4457,6 +4827,20 @@ Copyright © 2023 37signals LLC
4457
4827
  await this.renderElement(this.currentElement, this.newElement);
4458
4828
  }
4459
4829
 
4830
+ get unusedHeadStylesheetElements() {
4831
+ return this.oldHeadStylesheetElements.filter((element) => {
4832
+ return !(element.hasAttribute("data-turbo-permanent") ||
4833
+ // Trix dynamically adds styles to the head that we want to keep around which have a
4834
+ // `data-tag-name` attribute. Long term we should moves those styles to Trix's CSS file
4835
+ // but for now we'll just skip removing them
4836
+ element.hasAttribute("data-tag-name"))
4837
+ })
4838
+ }
4839
+
4840
+ get oldHeadStylesheetElements() {
4841
+ return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
4842
+ }
4843
+
4460
4844
  get newHeadStylesheetElements() {
4461
4845
  return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
4462
4846
  }
@@ -4700,6 +5084,7 @@ Copyright © 2023 37signals LLC
4700
5084
 
4701
5085
  pageObserver = new PageObserver(this)
4702
5086
  cacheObserver = new CacheObserver()
5087
+ linkPrefetchObserver = new LinkPrefetchObserver(this, document)
4703
5088
  linkClickObserver = new LinkClickObserver(this, window)
4704
5089
  formSubmitObserver = new FormSubmitObserver(this, document)
4705
5090
  scrollObserver = new ScrollObserver(this)
@@ -4714,16 +5099,20 @@ Copyright © 2023 37signals LLC
4714
5099
  progressBarDelay = 500
4715
5100
  started = false
4716
5101
  formMode = "on"
5102
+ #pageRefreshDebouncePeriod = 150
4717
5103
 
4718
5104
  constructor(recentRequests) {
4719
5105
  this.recentRequests = recentRequests;
4720
5106
  this.preloader = new Preloader(this, this.view.snapshotCache);
5107
+ this.debouncedRefresh = this.refresh;
5108
+ this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
4721
5109
  }
4722
5110
 
4723
5111
  start() {
4724
5112
  if (!this.started) {
4725
5113
  this.pageObserver.start();
4726
5114
  this.cacheObserver.start();
5115
+ this.linkPrefetchObserver.start();
4727
5116
  this.formLinkClickObserver.start();
4728
5117
  this.linkClickObserver.start();
4729
5118
  this.formSubmitObserver.start();
@@ -4745,6 +5134,7 @@ Copyright © 2023 37signals LLC
4745
5134
  if (this.started) {
4746
5135
  this.pageObserver.stop();
4747
5136
  this.cacheObserver.stop();
5137
+ this.linkPrefetchObserver.stop();
4748
5138
  this.formLinkClickObserver.stop();
4749
5139
  this.linkClickObserver.stop();
4750
5140
  this.formSubmitObserver.stop();
@@ -4812,6 +5202,15 @@ Copyright © 2023 37signals LLC
4812
5202
  return this.history.restorationIdentifier
4813
5203
  }
4814
5204
 
5205
+ get pageRefreshDebouncePeriod() {
5206
+ return this.#pageRefreshDebouncePeriod
5207
+ }
5208
+
5209
+ set pageRefreshDebouncePeriod(value) {
5210
+ this.refresh = debounce(this.debouncedRefresh.bind(this), value);
5211
+ this.#pageRefreshDebouncePeriod = value;
5212
+ }
5213
+
4815
5214
  // Preloader delegate
4816
5215
 
4817
5216
  shouldPreloadLink(element) {
@@ -4861,6 +5260,15 @@ Copyright © 2023 37signals LLC
4861
5260
 
4862
5261
  submittedFormLinkToLocation() {}
4863
5262
 
5263
+ // Link hover observer delegate
5264
+
5265
+ canPrefetchRequestToLocation(link, location) {
5266
+ return (
5267
+ this.elementIsNavigatable(link) &&
5268
+ locationIsVisitable(location, this.snapshot.rootLocation)
5269
+ )
5270
+ }
5271
+
4864
5272
  // Link click observer delegate
4865
5273
 
4866
5274
  willFollowLinkToLocation(link, location, event) {
@@ -4960,8 +5368,8 @@ Copyright © 2023 37signals LLC
4960
5368
  }
4961
5369
  }
4962
5370
 
4963
- allowsImmediateRender({ element }, isPreview, options) {
4964
- const event = this.notifyApplicationBeforeRender(element, isPreview, options);
5371
+ allowsImmediateRender({ element }, options) {
5372
+ const event = this.notifyApplicationBeforeRender(element, options);
4965
5373
  const {
4966
5374
  defaultPrevented,
4967
5375
  detail: { render }
@@ -4974,9 +5382,9 @@ Copyright © 2023 37signals LLC
4974
5382
  return !defaultPrevented
4975
5383
  }
4976
5384
 
4977
- viewRenderedSnapshot(_snapshot, isPreview, renderMethod) {
5385
+ viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) {
4978
5386
  this.view.lastRenderedLocation = this.history.location;
4979
- this.notifyApplicationAfterRender(isPreview, renderMethod);
5387
+ this.notifyApplicationAfterRender(renderMethod);
4980
5388
  }
4981
5389
 
4982
5390
  preloadOnLoadLinksForView(element) {
@@ -5032,15 +5440,15 @@ Copyright © 2023 37signals LLC
5032
5440
  return dispatch("turbo:before-cache")
5033
5441
  }
5034
5442
 
5035
- notifyApplicationBeforeRender(newBody, isPreview, options) {
5443
+ notifyApplicationBeforeRender(newBody, options) {
5036
5444
  return dispatch("turbo:before-render", {
5037
- detail: { newBody, isPreview, ...options },
5445
+ detail: { newBody, ...options },
5038
5446
  cancelable: true
5039
5447
  })
5040
5448
  }
5041
5449
 
5042
- notifyApplicationAfterRender(isPreview, renderMethod) {
5043
- return dispatch("turbo:render", { detail: { isPreview, renderMethod } })
5450
+ notifyApplicationAfterRender(renderMethod) {
5451
+ return dispatch("turbo:render", { detail: { renderMethod } })
5044
5452
  }
5045
5453
 
5046
5454
  notifyApplicationAfterPageLoad(timing = {}) {
@@ -5500,7 +5908,7 @@ Copyright © 2023 37signals LLC
5500
5908
 
5501
5909
  // View delegate
5502
5910
 
5503
- allowsImmediateRender({ element: newFrame }, _isPreview, options) {
5911
+ allowsImmediateRender({ element: newFrame }, options) {
5504
5912
  const event = dispatch("turbo:before-frame-render", {
5505
5913
  target: this.element,
5506
5914
  detail: { newFrame, ...options },