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