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

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.4
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) {
@@ -3014,6 +3083,173 @@ Copyright © 2023 37signals LLC
3014
3083
  }
3015
3084
  }
3016
3085
 
3086
+ class LinkPrefetchObserver {
3087
+ started = false
3088
+ hoverTriggerEvent = "mouseenter"
3089
+ touchTriggerEvent = "touchstart"
3090
+
3091
+ constructor(delegate, eventTarget) {
3092
+ this.delegate = delegate;
3093
+ this.eventTarget = eventTarget;
3094
+ }
3095
+
3096
+ start() {
3097
+ if (this.started) return
3098
+
3099
+ if (this.eventTarget.readyState === "loading") {
3100
+ this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true });
3101
+ } else {
3102
+ this.#enable();
3103
+ }
3104
+ }
3105
+
3106
+ stop() {
3107
+ if (!this.started) return
3108
+
3109
+ this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3110
+ capture: true,
3111
+ passive: true
3112
+ });
3113
+ this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3114
+ capture: true,
3115
+ passive: true
3116
+ });
3117
+ this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3118
+ this.started = false;
3119
+ }
3120
+
3121
+ #enable = () => {
3122
+ this.eventTarget.addEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3123
+ capture: true,
3124
+ passive: true
3125
+ });
3126
+ this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3127
+ capture: true,
3128
+ passive: true
3129
+ });
3130
+ this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3131
+ this.started = true;
3132
+ }
3133
+
3134
+ #tryToPrefetchRequest = (event) => {
3135
+ if (getMetaContent("turbo-prefetch") !== "true") return
3136
+
3137
+ const target = event.target;
3138
+ const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])");
3139
+
3140
+ if (isLink && this.#isPrefetchable(target)) {
3141
+ const link = target;
3142
+ const location = getLocationForLink(link);
3143
+
3144
+ if (this.delegate.canPrefetchRequestToLocation(link, location)) {
3145
+ const fetchRequest = new FetchRequest(
3146
+ this,
3147
+ FetchMethod.get,
3148
+ location,
3149
+ new URLSearchParams(),
3150
+ target
3151
+ );
3152
+
3153
+ prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
3154
+
3155
+ link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true });
3156
+ }
3157
+ }
3158
+ }
3159
+
3160
+ #tryToUsePrefetchedRequest = (event) => {
3161
+ if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") {
3162
+ const cached = prefetchCache.get(event.detail.url.toString());
3163
+
3164
+ if (cached) {
3165
+ // User clicked link, use cache response
3166
+ event.detail.fetchRequest = cached;
3167
+ }
3168
+
3169
+ prefetchCache.clear();
3170
+ }
3171
+ }
3172
+
3173
+ prepareRequest(request) {
3174
+ const link = request.target;
3175
+
3176
+ request.headers["Sec-Purpose"] = "prefetch";
3177
+
3178
+ const turboFrame = link.closest("turbo-frame");
3179
+ const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id;
3180
+
3181
+ if (turboFrameTarget && turboFrameTarget !== "_top") {
3182
+ request.headers["Turbo-Frame"] = turboFrameTarget;
3183
+ }
3184
+
3185
+ if (link.hasAttribute("data-turbo-stream")) {
3186
+ request.acceptResponseType("text/vnd.turbo-stream.html");
3187
+ }
3188
+ }
3189
+
3190
+ // Fetch request interface
3191
+
3192
+ requestSucceededWithResponse() {}
3193
+
3194
+ requestStarted(fetchRequest) {}
3195
+
3196
+ requestErrored(fetchRequest) {}
3197
+
3198
+ requestFinished(fetchRequest) {}
3199
+
3200
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
3201
+
3202
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
3203
+
3204
+ get #cacheTtl() {
3205
+ return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
3206
+ }
3207
+
3208
+ #isPrefetchable(link) {
3209
+ const href = link.getAttribute("href");
3210
+
3211
+ if (!href || href === "#" || link.dataset.turbo === "false" || link.dataset.turboPrefetch === "false") {
3212
+ return false
3213
+ }
3214
+
3215
+ if (link.origin !== document.location.origin) {
3216
+ return false
3217
+ }
3218
+
3219
+ if (!["http:", "https:"].includes(link.protocol)) {
3220
+ return false
3221
+ }
3222
+
3223
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3224
+ return false
3225
+ }
3226
+
3227
+ if (link.dataset.turboMethod && link.dataset.turboMethod !== "get") {
3228
+ return false
3229
+ }
3230
+
3231
+ if (targetsIframe(link)) {
3232
+ return false
3233
+ }
3234
+
3235
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3236
+ return false
3237
+ }
3238
+
3239
+ const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]");
3240
+
3241
+ if (turboPrefetchParent && turboPrefetchParent.dataset.turboPrefetch === "false") {
3242
+ return false
3243
+ }
3244
+
3245
+ return true
3246
+ }
3247
+ }
3248
+
3249
+ const targetsIframe = (link) => {
3250
+ return !doesNotTargetIFrame(link)
3251
+ };
3252
+
3017
3253
  class Navigator {
3018
3254
  constructor(delegate) {
3019
3255
  this.delegate = delegate;
@@ -3484,722 +3720,838 @@ Copyright © 2023 37signals LLC
3484
3720
  }
3485
3721
  }
3486
3722
 
3487
- let EMPTY_SET = new Set();
3723
+ // base IIFE to define idiomorph
3724
+ var Idiomorph = (function () {
3488
3725
 
3489
- //=============================================================================
3490
- // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3491
- //=============================================================================
3492
- function morph(oldNode, newContent, config = {}) {
3726
+ //=============================================================================
3727
+ // AND NOW IT BEGINS...
3728
+ //=============================================================================
3729
+ let EMPTY_SET = new Set();
3493
3730
 
3494
- if (oldNode instanceof Document) {
3495
- oldNode = oldNode.documentElement;
3496
- }
3497
-
3498
- if (typeof newContent === 'string') {
3499
- newContent = parseContent(newContent);
3500
- }
3731
+ // default configuration values, updatable by users now
3732
+ let defaults = {
3733
+ morphStyle: "outerHTML",
3734
+ callbacks : {
3735
+ beforeNodeAdded: noOp,
3736
+ afterNodeAdded: noOp,
3737
+ beforeNodeMorphed: noOp,
3738
+ afterNodeMorphed: noOp,
3739
+ beforeNodeRemoved: noOp,
3740
+ afterNodeRemoved: noOp,
3741
+ beforeAttributeUpdated: noOp,
3501
3742
 
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
- }
3743
+ },
3744
+ head: {
3745
+ style: 'merge',
3746
+ shouldPreserve: function (elt) {
3747
+ return elt.getAttribute("im-preserve") === "true";
3748
+ },
3749
+ shouldReAppend: function (elt) {
3750
+ return elt.getAttribute("im-re-append") === "true";
3751
+ },
3752
+ shouldRemove: noOp,
3753
+ afterHeadMorphed: noOp,
3754
+ }
3755
+ };
3527
3756
 
3528
- if (ctx.morphStyle === "innerHTML") {
3757
+ //=============================================================================
3758
+ // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3759
+ //=============================================================================
3760
+ function morph(oldNode, newContent, config = {}) {
3529
3761
 
3530
- // innerHTML, so we are only updating the children
3531
- morphChildren(normalizedNewContent, oldNode, ctx);
3532
- return oldNode.children;
3762
+ if (oldNode instanceof Document) {
3763
+ oldNode = oldNode.documentElement;
3764
+ }
3533
3765
 
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);
3766
+ if (typeof newContent === 'string') {
3767
+ newContent = parseContent(newContent);
3768
+ }
3538
3769
 
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;
3770
+ let normalizedContent = normalizeContent(newContent);
3542
3771
 
3543
- // morph it
3544
- let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3772
+ let ctx = createMorphContext(oldNode, normalizedContent, config);
3545
3773
 
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 []
3774
+ return morphNormalizedContent(oldNode, normalizedContent, ctx);
3553
3775
  }
3554
- } else {
3555
- throw "Do not understand how to morph style " + ctx.morphStyle;
3556
- }
3557
- }
3558
-
3559
3776
 
3777
+ function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3778
+ if (ctx.head.block) {
3779
+ let oldHead = oldNode.querySelector('head');
3780
+ let newHead = normalizedNewContent.querySelector('head');
3781
+ if (oldHead && newHead) {
3782
+ let promises = handleHeadElement(newHead, oldHead, ctx);
3783
+ // when head promises resolve, call morph again, ignoring the head tag
3784
+ Promise.all(promises).then(function () {
3785
+ morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3786
+ head: {
3787
+ block: false,
3788
+ ignore: true
3789
+ }
3790
+ }));
3791
+ });
3792
+ return;
3793
+ }
3794
+ }
3560
3795
 
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;
3796
+ if (ctx.morphStyle === "innerHTML") {
3584
3797
 
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
- }
3798
+ // innerHTML, so we are only updating the children
3799
+ morphChildren(normalizedNewContent, oldNode, ctx);
3800
+ return oldNode.children;
3595
3801
 
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) {
3802
+ } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3803
+ // otherwise find the best element match in the new content, morph that, and merge its siblings
3804
+ // into either side of the best match
3805
+ let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3619
3806
 
3620
- let nextNewChild = newParent.firstChild;
3621
- let insertionPoint = oldParent.firstChild;
3622
- let newChild;
3807
+ // stash the siblings that will need to be inserted on either side of the best match
3808
+ let previousSibling = bestMatch?.previousSibling;
3809
+ let nextSibling = bestMatch?.nextSibling;
3623
3810
 
3624
- // run through all the new content
3625
- while (nextNewChild) {
3811
+ // morph it
3812
+ let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3626
3813
 
3627
- newChild = nextNewChild;
3628
- nextNewChild = newChild.nextSibling;
3814
+ if (bestMatch) {
3815
+ // if there was a best match, merge the siblings in too and return the
3816
+ // whole bunch
3817
+ return insertSiblings(previousSibling, morphedNode, nextSibling);
3818
+ } else {
3819
+ // otherwise nothing was added to the DOM
3820
+ return []
3821
+ }
3822
+ } else {
3823
+ throw "Do not understand how to morph style " + ctx.morphStyle;
3824
+ }
3825
+ }
3629
3826
 
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
3827
 
3634
- oldParent.appendChild(newChild);
3635
- ctx.callbacks.afterNodeAdded(newChild);
3636
- removeIdsFromConsideration(ctx, newChild);
3637
- continue;
3828
+ /**
3829
+ * @param possibleActiveElement
3830
+ * @param ctx
3831
+ * @returns {boolean}
3832
+ */
3833
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
3834
+ return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
3638
3835
  }
3639
3836
 
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;
3837
+ /**
3838
+ * @param oldNode root node to merge content into
3839
+ * @param newContent new content to merge
3840
+ * @param ctx the merge context
3841
+ * @returns {Element} the element that ended up in the DOM
3842
+ */
3843
+ function morphOldNodeTo(oldNode, newContent, ctx) {
3844
+ if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3845
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3846
+
3847
+ oldNode.remove();
3848
+ ctx.callbacks.afterNodeRemoved(oldNode);
3849
+ return null;
3850
+ } else if (!isSoftMatch(oldNode, newContent)) {
3851
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3852
+ if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
3853
+
3854
+ oldNode.parentElement.replaceChild(newContent, oldNode);
3855
+ ctx.callbacks.afterNodeAdded(newContent);
3856
+ ctx.callbacks.afterNodeRemoved(oldNode);
3857
+ return newContent;
3858
+ } else {
3859
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
3860
+
3861
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3862
+ handleHeadElement(newContent, oldNode, ctx);
3863
+ } else {
3864
+ syncNodeFrom(newContent, oldNode, ctx);
3865
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
3866
+ morphChildren(newContent, oldNode, ctx);
3867
+ }
3868
+ }
3869
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3870
+ return oldNode;
3871
+ }
3646
3872
  }
3647
3873
 
3648
- // otherwise search forward in the existing old children for an id set match
3649
- let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3874
+ /**
3875
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3876
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3877
+ * by using id sets, we are able to better match up with content deeper in the DOM.
3878
+ *
3879
+ * Basic algorithm is, for each node in the new content:
3880
+ *
3881
+ * - if we have reached the end of the old parent, append the new content
3882
+ * - if the new content has an id set match with the current insertion point, morph
3883
+ * - search for an id set match
3884
+ * - if id set match found, morph
3885
+ * - otherwise search for a "soft" match
3886
+ * - if a soft match is found, morph
3887
+ * - otherwise, prepend the new node before the current insertion point
3888
+ *
3889
+ * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3890
+ * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3891
+ *
3892
+ * @param {Element} newParent the parent element of the new content
3893
+ * @param {Element } oldParent the old content that we are merging the new content into
3894
+ * @param ctx the merge context
3895
+ */
3896
+ function morphChildren(newParent, oldParent, ctx) {
3897
+
3898
+ let nextNewChild = newParent.firstChild;
3899
+ let insertionPoint = oldParent.firstChild;
3900
+ let newChild;
3901
+
3902
+ // run through all the new content
3903
+ while (nextNewChild) {
3904
+
3905
+ newChild = nextNewChild;
3906
+ nextNewChild = newChild.nextSibling;
3907
+
3908
+ // if we are at the end of the exiting parent's children, just append
3909
+ if (insertionPoint == null) {
3910
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3911
+
3912
+ oldParent.appendChild(newChild);
3913
+ ctx.callbacks.afterNodeAdded(newChild);
3914
+ removeIdsFromConsideration(ctx, newChild);
3915
+ continue;
3916
+ }
3650
3917
 
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
- }
3918
+ // if the current node has an id set match then morph
3919
+ if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3920
+ morphOldNodeTo(insertionPoint, newChild, ctx);
3921
+ insertionPoint = insertionPoint.nextSibling;
3922
+ removeIdsFromConsideration(ctx, newChild);
3923
+ continue;
3924
+ }
3658
3925
 
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);
3926
+ // otherwise search forward in the existing old children for an id set match
3927
+ let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3661
3928
 
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
- }
3929
+ // if we found a potential match, remove the nodes until that point and morph
3930
+ if (idSetMatch) {
3931
+ insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3932
+ morphOldNodeTo(idSetMatch, newChild, ctx);
3933
+ removeIdsFromConsideration(ctx, newChild);
3934
+ continue;
3935
+ }
3669
3936
 
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;
3937
+ // no id set match found, so scan forward for a soft match for the current node
3938
+ let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3673
3939
 
3674
- oldParent.insertBefore(newChild, insertionPoint);
3675
- ctx.callbacks.afterNodeAdded(newChild);
3676
- removeIdsFromConsideration(ctx, newChild);
3677
- }
3940
+ // if we found a soft match for the current node, morph
3941
+ if (softMatch) {
3942
+ insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3943
+ morphOldNodeTo(softMatch, newChild, ctx);
3944
+ removeIdsFromConsideration(ctx, newChild);
3945
+ continue;
3946
+ }
3678
3947
 
3679
- // remove any remaining old nodes that didn't match up with new content
3680
- while (insertionPoint !== null) {
3948
+ // abandon all hope of morphing, just insert the new child before the insertion point
3949
+ // and move on
3950
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3681
3951
 
3682
- let tempNode = insertionPoint;
3683
- insertionPoint = insertionPoint.nextSibling;
3684
- removeNode(tempNode, ctx);
3685
- }
3686
- }
3952
+ oldParent.insertBefore(newChild, insertionPoint);
3953
+ ctx.callbacks.afterNodeAdded(newChild);
3954
+ removeIdsFromConsideration(ctx, newChild);
3955
+ }
3687
3956
 
3688
- //=============================================================================
3689
- // Attribute Syncing Code
3690
- //=============================================================================
3957
+ // remove any remaining old nodes that didn't match up with new content
3958
+ while (insertionPoint !== null) {
3691
3959
 
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);
3960
+ let tempNode = insertionPoint;
3961
+ insertionPoint = insertionPoint.nextSibling;
3962
+ removeNode(tempNode, ctx);
3710
3963
  }
3711
3964
  }
3712
- for (const toAttribute of toAttributes) {
3713
- if (!from.hasAttribute(toAttribute.name)) {
3714
- to.removeAttribute(toAttribute.name);
3965
+
3966
+ //=============================================================================
3967
+ // Attribute Syncing Code
3968
+ //=============================================================================
3969
+
3970
+ /**
3971
+ * @param attr {String} the attribute to be mutated
3972
+ * @param to {Element} the element that is going to be updated
3973
+ * @param updateType {("update"|"remove")}
3974
+ * @param ctx the merge context
3975
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
3976
+ */
3977
+ function ignoreAttribute(attr, to, updateType, ctx) {
3978
+ if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
3979
+ return true;
3715
3980
  }
3981
+ return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
3716
3982
  }
3717
- }
3718
3983
 
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
- }
3984
+ /**
3985
+ * syncs a given node with another node, copying over all attributes and
3986
+ * inner element state from the 'from' node to the 'to' node
3987
+ *
3988
+ * @param {Element} from the element to copy attributes & state from
3989
+ * @param {Element} to the element to copy attributes & state to
3990
+ * @param ctx the merge context
3991
+ */
3992
+ function syncNodeFrom(from, to, ctx) {
3993
+ let type = from.nodeType;
3994
+
3995
+ // if is an element type, sync the attributes from the
3996
+ // new node into the new node
3997
+ if (type === 1 /* element type */) {
3998
+ const fromAttributes = from.attributes;
3999
+ const toAttributes = to.attributes;
4000
+ for (const fromAttribute of fromAttributes) {
4001
+ if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
4002
+ continue;
4003
+ }
4004
+ if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
4005
+ to.setAttribute(fromAttribute.name, fromAttribute.value);
4006
+ }
4007
+ }
4008
+ // iterate backwards to avoid skipping over items when a delete occurs
4009
+ for (let i = toAttributes.length - 1; 0 <= i; i--) {
4010
+ const toAttribute = toAttributes[i];
4011
+ if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
4012
+ continue;
4013
+ }
4014
+ if (!from.hasAttribute(toAttribute.name)) {
4015
+ to.removeAttribute(toAttribute.name);
4016
+ }
4017
+ }
4018
+ }
3725
4019
 
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
- }
4020
+ // sync text nodes
4021
+ if (type === 8 /* comment */ || type === 3 /* text */) {
4022
+ if (to.nodeValue !== from.nodeValue) {
4023
+ to.nodeValue = from.nodeValue;
4024
+ }
4025
+ }
3755
4026
 
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);
4027
+ if (!ignoreValueOfActiveElement(to, ctx)) {
4028
+ // sync input values
4029
+ syncInputValue(from, to, ctx);
4030
+ }
3762
4031
  }
3763
- }
3764
- }
3765
4032
 
3766
- //=============================================================================
3767
- // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
3768
- //=============================================================================
3769
- function handleHeadElement(newHeadTag, currentHead, ctx) {
4033
+ /**
4034
+ * @param from {Element} element to sync the value from
4035
+ * @param to {Element} element to sync the value to
4036
+ * @param attributeName {String} the attribute name
4037
+ * @param ctx the merge context
4038
+ */
4039
+ function syncBooleanAttribute(from, to, attributeName, ctx) {
4040
+ if (from[attributeName] !== to[attributeName]) {
4041
+ let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
4042
+ if (!ignoreUpdate) {
4043
+ to[attributeName] = from[attributeName];
4044
+ }
4045
+ if (from[attributeName]) {
4046
+ if (!ignoreUpdate) {
4047
+ to.setAttribute(attributeName, from[attributeName]);
4048
+ }
4049
+ } else {
4050
+ if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
4051
+ to.removeAttribute(attributeName);
4052
+ }
4053
+ }
4054
+ }
4055
+ }
3770
4056
 
3771
- let added = [];
3772
- let removed = [];
3773
- let preserved = [];
3774
- let nodesToAppend = [];
4057
+ /**
4058
+ * NB: many bothans died to bring us information:
4059
+ *
4060
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
4061
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
4062
+ *
4063
+ * @param from {Element} the element to sync the input value from
4064
+ * @param to {Element} the element to sync the input value to
4065
+ * @param ctx the merge context
4066
+ */
4067
+ function syncInputValue(from, to, ctx) {
4068
+ if (from instanceof HTMLInputElement &&
4069
+ to instanceof HTMLInputElement &&
4070
+ from.type !== 'file') {
4071
+
4072
+ let fromValue = from.value;
4073
+ let toValue = to.value;
4074
+
4075
+ // sync boolean attributes
4076
+ syncBooleanAttribute(from, to, 'checked', ctx);
4077
+ syncBooleanAttribute(from, to, 'disabled', ctx);
4078
+
4079
+ if (!from.hasAttribute('value')) {
4080
+ if (!ignoreAttribute('value', to, 'remove', ctx)) {
4081
+ to.value = '';
4082
+ to.removeAttribute('value');
4083
+ }
4084
+ } else if (fromValue !== toValue) {
4085
+ if (!ignoreAttribute('value', to, 'update', ctx)) {
4086
+ to.setAttribute('value', fromValue);
4087
+ to.value = fromValue;
4088
+ }
4089
+ }
4090
+ } else if (from instanceof HTMLOptionElement) {
4091
+ syncBooleanAttribute(from, to, 'selected', ctx);
4092
+ } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
4093
+ let fromValue = from.value;
4094
+ let toValue = to.value;
4095
+ if (ignoreAttribute('value', to, 'update', ctx)) {
4096
+ return;
4097
+ }
4098
+ if (fromValue !== toValue) {
4099
+ to.value = fromValue;
4100
+ }
4101
+ if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
4102
+ to.firstChild.nodeValue = fromValue;
4103
+ }
4104
+ }
4105
+ }
3775
4106
 
3776
- let headMergeStyle = ctx.head.style;
4107
+ //=============================================================================
4108
+ // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
4109
+ //=============================================================================
4110
+ function handleHeadElement(newHeadTag, currentHead, ctx) {
3777
4111
 
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
- }
4112
+ let added = [];
4113
+ let removed = [];
4114
+ let preserved = [];
4115
+ let nodesToAppend = [];
3783
4116
 
3784
- // for each elt in the current head
3785
- for (const currentHeadElt of currentHead.children) {
4117
+ let headMergeStyle = ctx.head.style;
3786
4118
 
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);
4119
+ // put all new head elements into a Map, by their outerHTML
4120
+ let srcToNewHeadNodes = new Map();
4121
+ for (const newHeadChild of newHeadTag.children) {
4122
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3800
4123
  }
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);
4124
+
4125
+ // for each elt in the current head
4126
+ for (const currentHeadElt of currentHead.children) {
4127
+
4128
+ // If the current head element is in the map
4129
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
4130
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
4131
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
4132
+ if (inNewContent || isPreserved) {
4133
+ if (isReAppended) {
4134
+ // remove the current version and let the new version replace it and re-execute
4135
+ removed.push(currentHeadElt);
4136
+ } else {
4137
+ // this element already exists and should not be re-appended, so remove it from
4138
+ // the new content map, preserving it in the DOM
4139
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
4140
+ preserved.push(currentHeadElt);
4141
+ }
4142
+ } else {
4143
+ if (headMergeStyle === "append") {
4144
+ // we are appending and this existing element is not new content
4145
+ // so if and only if it is marked for re-append do we do anything
4146
+ if (isReAppended) {
4147
+ removed.push(currentHeadElt);
4148
+ nodesToAppend.push(currentHeadElt);
4149
+ }
4150
+ } else {
4151
+ // if this is a merge, we remove this content since it is not in the new head
4152
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
4153
+ removed.push(currentHeadElt);
4154
+ }
4155
+ }
3808
4156
  }
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);
4157
+ }
4158
+
4159
+ // Push the remaining new head elements in the Map into the
4160
+ // nodes to append to the head tag
4161
+ nodesToAppend.push(...srcToNewHeadNodes.values());
4162
+
4163
+ let promises = [];
4164
+ for (const newNode of nodesToAppend) {
4165
+ let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
4166
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
4167
+ if (newElt.href || newElt.src) {
4168
+ let resolve = null;
4169
+ let promise = new Promise(function (_resolve) {
4170
+ resolve = _resolve;
4171
+ });
4172
+ newElt.addEventListener('load', function () {
4173
+ resolve();
4174
+ });
4175
+ promises.push(promise);
4176
+ }
4177
+ currentHead.appendChild(newElt);
4178
+ ctx.callbacks.afterNodeAdded(newElt);
4179
+ added.push(newElt);
3813
4180
  }
3814
4181
  }
3815
- }
3816
- }
3817
4182
 
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);
4183
+ // remove all removed elements, after we have appended the new elements to avoid
4184
+ // additional network requests for things like style sheets
4185
+ for (const removedElement of removed) {
4186
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
4187
+ currentHead.removeChild(removedElement);
4188
+ ctx.callbacks.afterNodeRemoved(removedElement);
4189
+ }
3835
4190
  }
3836
- currentHead.appendChild(newElt);
3837
- ctx.callbacks.afterNodeAdded(newElt);
3838
- added.push(newElt);
3839
- }
3840
- }
3841
4191
 
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);
4192
+ ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
4193
+ return promises;
3848
4194
  }
3849
- }
3850
4195
 
3851
- ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
3852
- return promises;
3853
- }
4196
+ function noOp() {
4197
+ }
3854
4198
 
3855
- function noOp() {}
4199
+ /*
4200
+ Deep merges the config object and the Idiomoroph.defaults object to
4201
+ produce a final configuration object
4202
+ */
4203
+ function mergeDefaults(config) {
4204
+ let finalConfig = {};
4205
+ // copy top level stuff into final config
4206
+ Object.assign(finalConfig, defaults);
4207
+ Object.assign(finalConfig, config);
4208
+
4209
+ // copy callbacks into final config (do this to deep merge the callbacks)
4210
+ finalConfig.callbacks = {};
4211
+ Object.assign(finalConfig.callbacks, defaults.callbacks);
4212
+ Object.assign(finalConfig.callbacks, config.callbacks);
4213
+
4214
+ // copy head config into final config (do this to deep merge the head)
4215
+ finalConfig.head = {};
4216
+ Object.assign(finalConfig.head, defaults.head);
4217
+ Object.assign(finalConfig.head, config.head);
4218
+ return finalConfig;
4219
+ }
3856
4220
 
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
- }
4221
+ function createMorphContext(oldNode, newContent, config) {
4222
+ config = mergeDefaults(config);
4223
+ return {
4224
+ target: oldNode,
4225
+ newContent: newContent,
4226
+ config: config,
4227
+ morphStyle: config.morphStyle,
4228
+ ignoreActive: config.ignoreActive,
4229
+ ignoreActiveValue: config.ignoreActiveValue,
4230
+ idMap: createIdMap(oldNode, newContent),
4231
+ deadIds: new Set(),
4232
+ callbacks: config.callbacks,
4233
+ head: config.head
4234
+ }
4235
+ }
3888
4236
 
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;
4237
+ function isIdSetMatch(node1, node2, ctx) {
4238
+ if (node1 == null || node2 == null) {
4239
+ return false;
4240
+ }
4241
+ if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
4242
+ if (node1.id !== "" && node1.id === node2.id) {
4243
+ return true;
4244
+ } else {
4245
+ return getIdIntersectionCount(ctx, node1, node2) > 0;
4246
+ }
4247
+ }
4248
+ return false;
3898
4249
  }
3899
- }
3900
- return false;
3901
- }
3902
4250
 
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
- }
4251
+ function isSoftMatch(node1, node2) {
4252
+ if (node1 == null || node2 == null) {
4253
+ return false;
4254
+ }
4255
+ return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
4256
+ }
3909
4257
 
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
- }
4258
+ function removeNodesBetween(startInclusive, endExclusive, ctx) {
4259
+ while (startInclusive !== endExclusive) {
4260
+ let tempNode = startInclusive;
4261
+ startInclusive = startInclusive.nextSibling;
4262
+ removeNode(tempNode, ctx);
4263
+ }
4264
+ removeIdsFromConsideration(ctx, endExclusive);
4265
+ return endExclusive.nextSibling;
4266
+ }
3919
4267
 
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) {
4268
+ //=============================================================================
4269
+ // Scans forward from the insertionPoint in the old parent looking for a potential id match
4270
+ // for the newChild. We stop if we find a potential id match for the new child OR
4271
+ // if the number of potential id matches we are discarding is greater than the
4272
+ // potential id matches for the new child
4273
+ //=============================================================================
4274
+ function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4275
+
4276
+ // max id matches we are willing to discard in our search
4277
+ let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4278
+
4279
+ let potentialMatch = null;
4280
+
4281
+ // only search forward if there is a possibility of an id match
4282
+ if (newChildPotentialIdCount > 0) {
4283
+ let potentialMatch = insertionPoint;
4284
+ // if there is a possibility of an id match, scan forward
4285
+ // keep track of the potential id match count we are discarding (the
4286
+ // newChildPotentialIdCount must be greater than this to make it likely
4287
+ // worth it)
4288
+ let otherMatchCount = 0;
4289
+ while (potentialMatch != null) {
4290
+
4291
+ // If we have an id match, return the current potential match
4292
+ if (isIdSetMatch(newChild, potentialMatch, ctx)) {
4293
+ return potentialMatch;
4294
+ }
3927
4295
 
3928
- // max id matches we are willing to discard in our search
3929
- let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4296
+ // computer the other potential matches of this new content
4297
+ otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
4298
+ if (otherMatchCount > newChildPotentialIdCount) {
4299
+ // if we have more potential id matches in _other_ content, we
4300
+ // do not have a good candidate for an id match, so return null
4301
+ return null;
4302
+ }
3930
4303
 
3931
- let potentialMatch = null;
4304
+ // advanced to the next old content child
4305
+ potentialMatch = potentialMatch.nextSibling;
4306
+ }
4307
+ }
4308
+ return potentialMatch;
4309
+ }
3932
4310
 
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) {
4311
+ //=============================================================================
4312
+ // Scans forward from the insertionPoint in the old parent looking for a potential soft match
4313
+ // for the newChild. We stop if we find a potential soft match for the new child OR
4314
+ // if we find a potential id match in the old parents children OR if we find two
4315
+ // potential soft matches for the next two pieces of new content
4316
+ //=============================================================================
4317
+ function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
3942
4318
 
3943
- // If we have an id match, return the current potential match
3944
- if (isIdSetMatch(newChild, potentialMatch, ctx)) {
3945
- return potentialMatch;
3946
- }
4319
+ let potentialSoftMatch = insertionPoint;
4320
+ let nextSibling = newChild.nextSibling;
4321
+ let siblingSoftMatchCount = 0;
3947
4322
 
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
- }
4323
+ while (potentialSoftMatch != null) {
3955
4324
 
3956
- // advanced to the next old content child
3957
- potentialMatch = potentialMatch.nextSibling;
3958
- }
3959
- }
3960
- return potentialMatch;
3961
- }
4325
+ if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
4326
+ // the current potential soft match has a potential id set match with the remaining new
4327
+ // content so bail out of looking
4328
+ return null;
4329
+ }
3962
4330
 
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) {
4331
+ // if we have a soft match with the current node, return it
4332
+ if (isSoftMatch(newChild, potentialSoftMatch)) {
4333
+ return potentialSoftMatch;
4334
+ }
3970
4335
 
3971
- let potentialSoftMatch = insertionPoint;
3972
- let nextSibling = newChild.nextSibling;
3973
- let siblingSoftMatchCount = 0;
4336
+ if (isSoftMatch(nextSibling, potentialSoftMatch)) {
4337
+ // the next new node has a soft match with this node, so
4338
+ // increment the count of future soft matches
4339
+ siblingSoftMatchCount++;
4340
+ nextSibling = nextSibling.nextSibling;
3974
4341
 
3975
- while (potentialSoftMatch != null) {
4342
+ // If there are two future soft matches, bail to allow the siblings to soft match
4343
+ // so that we don't consume future soft matches for the sake of the current node
4344
+ if (siblingSoftMatchCount >= 2) {
4345
+ return null;
4346
+ }
4347
+ }
3976
4348
 
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
- }
4349
+ // advanced to the next old content child
4350
+ potentialSoftMatch = potentialSoftMatch.nextSibling;
4351
+ }
3982
4352
 
3983
- // if we have a soft match with the current node, return it
3984
- if (isSoftMatch(newChild, potentialSoftMatch)) {
3985
4353
  return potentialSoftMatch;
3986
4354
  }
3987
4355
 
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;
4356
+ function parseContent(newContent) {
4357
+ let parser = new DOMParser();
4358
+
4359
+ // remove svgs to avoid false-positive matches on head, etc.
4360
+ let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
4361
+
4362
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
4363
+ if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
4364
+ let content = parser.parseFromString(newContent, "text/html");
4365
+ // if it is a full HTML document, return the document itself as the parent container
4366
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
4367
+ content.generatedByIdiomorph = true;
4368
+ return content;
4369
+ } else {
4370
+ // otherwise return the html element as the parent container
4371
+ let htmlElement = content.firstChild;
4372
+ if (htmlElement) {
4373
+ htmlElement.generatedByIdiomorph = true;
4374
+ return htmlElement;
4375
+ } else {
4376
+ return null;
4377
+ }
4378
+ }
4379
+ } else {
4380
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
4381
+ // deal with touchy tags like tr, tbody, etc.
4382
+ let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
4383
+ let content = responseDoc.body.querySelector('template').content;
4384
+ content.generatedByIdiomorph = true;
4385
+ return content
3998
4386
  }
3999
4387
  }
4000
4388
 
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;
4389
+ function normalizeContent(newContent) {
4390
+ if (newContent == null) {
4391
+ // noinspection UnnecessaryLocalVariableJS
4392
+ const dummyParent = document.createElement('div');
4393
+ return dummyParent;
4394
+ } else if (newContent.generatedByIdiomorph) {
4395
+ // the template tag created by idiomorph parsing can serve as a dummy parent
4396
+ return newContent;
4397
+ } else if (newContent instanceof Node) {
4398
+ // a single node is added as a child to a dummy parent
4399
+ const dummyParent = document.createElement('div');
4400
+ dummyParent.append(newContent);
4401
+ return dummyParent;
4027
4402
  } else {
4028
- return null;
4403
+ // all nodes in the array or HTMLElement collection are consolidated under
4404
+ // a single dummy parent element
4405
+ const dummyParent = document.createElement('div');
4406
+ for (const elt of [...newContent]) {
4407
+ dummyParent.append(elt);
4408
+ }
4409
+ return dummyParent;
4029
4410
  }
4030
4411
  }
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
4412
 
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
- }
4413
+ function insertSiblings(previousSibling, morphedNode, nextSibling) {
4414
+ let stack = [];
4415
+ let added = [];
4416
+ while (previousSibling != null) {
4417
+ stack.push(previousSibling);
4418
+ previousSibling = previousSibling.previousSibling;
4419
+ }
4420
+ while (stack.length > 0) {
4421
+ let node = stack.pop();
4422
+ added.push(node); // push added preceding siblings on in order and insert
4423
+ morphedNode.parentElement.insertBefore(node, morphedNode);
4424
+ }
4425
+ added.push(morphedNode);
4426
+ while (nextSibling != null) {
4427
+ stack.push(nextSibling);
4428
+ added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4429
+ nextSibling = nextSibling.nextSibling;
4430
+ }
4431
+ while (stack.length > 0) {
4432
+ morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4433
+ }
4434
+ return added;
4435
+ }
4088
4436
 
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;
4437
+ function findBestNodeMatch(newContent, oldNode, ctx) {
4438
+ let currentElement;
4439
+ currentElement = newContent.firstChild;
4440
+ let bestElement = currentElement;
4441
+ let score = 0;
4442
+ while (currentElement) {
4443
+ let newScore = scoreElement(currentElement, oldNode, ctx);
4444
+ if (newScore > score) {
4445
+ bestElement = currentElement;
4446
+ score = newScore;
4447
+ }
4448
+ currentElement = currentElement.nextSibling;
4449
+ }
4450
+ return bestElement;
4099
4451
  }
4100
- currentElement = currentElement.nextSibling;
4101
- }
4102
- return bestElement;
4103
- }
4104
4452
 
4105
- function scoreElement(node1, node2, ctx) {
4106
- if (isSoftMatch(node1, node2)) {
4107
- return .5 + getIdIntersectionCount(ctx, node1, node2);
4108
- }
4109
- return 0;
4110
- }
4453
+ function scoreElement(node1, node2, ctx) {
4454
+ if (isSoftMatch(node1, node2)) {
4455
+ return .5 + getIdIntersectionCount(ctx, node1, node2);
4456
+ }
4457
+ return 0;
4458
+ }
4111
4459
 
4112
- function removeNode(tempNode, ctx) {
4113
- removeIdsFromConsideration(ctx, tempNode);
4114
- if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4460
+ function removeNode(tempNode, ctx) {
4461
+ removeIdsFromConsideration(ctx, tempNode);
4462
+ if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4115
4463
 
4116
- tempNode.remove();
4117
- ctx.callbacks.afterNodeRemoved(tempNode);
4118
- }
4464
+ tempNode.remove();
4465
+ ctx.callbacks.afterNodeRemoved(tempNode);
4466
+ }
4119
4467
 
4120
- //=============================================================================
4121
- // ID Set Functions
4122
- //=============================================================================
4468
+ //=============================================================================
4469
+ // ID Set Functions
4470
+ //=============================================================================
4123
4471
 
4124
- function isIdInConsideration(ctx, id) {
4125
- return !ctx.deadIds.has(id);
4126
- }
4472
+ function isIdInConsideration(ctx, id) {
4473
+ return !ctx.deadIds.has(id);
4474
+ }
4127
4475
 
4128
- function idIsWithinNode(ctx, id, targetNode) {
4129
- let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4130
- return idSet.has(id);
4131
- }
4476
+ function idIsWithinNode(ctx, id, targetNode) {
4477
+ let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4478
+ return idSet.has(id);
4479
+ }
4132
4480
 
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
- }
4481
+ function removeIdsFromConsideration(ctx, node) {
4482
+ let idSet = ctx.idMap.get(node) || EMPTY_SET;
4483
+ for (const id of idSet) {
4484
+ ctx.deadIds.add(id);
4485
+ }
4486
+ }
4139
4487
 
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;
4488
+ function getIdIntersectionCount(ctx, node1, node2) {
4489
+ let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4490
+ let matchCount = 0;
4491
+ for (const id of sourceSet) {
4492
+ // a potential match is an id in the source and potentialIdsSet, but
4493
+ // that has not already been merged into the DOM
4494
+ if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4495
+ ++matchCount;
4496
+ }
4497
+ }
4498
+ return matchCount;
4148
4499
  }
4149
- }
4150
- return matchCount;
4151
- }
4152
4500
 
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);
4501
+ /**
4502
+ * A bottom up algorithm that finds all elements with ids inside of the node
4503
+ * argument and populates id sets for those nodes and all their parents, generating
4504
+ * a set of ids contained within all nodes for the entire hierarchy in the DOM
4505
+ *
4506
+ * @param node {Element}
4507
+ * @param {Map<Node, Set<String>>} idMap
4508
+ */
4509
+ function populateIdMapForNode(node, idMap) {
4510
+ let nodeParent = node.parentElement;
4511
+ // find all elements with an id property
4512
+ let idElements = node.querySelectorAll('[id]');
4513
+ for (const elt of idElements) {
4514
+ let current = elt;
4515
+ // walk up the parent hierarchy of that element, adding the id
4516
+ // of element to the parent's id set
4517
+ while (current !== nodeParent && current != null) {
4518
+ let idSet = idMap.get(current);
4519
+ // if the id set doesn't exist, create it and insert it in the map
4520
+ if (idSet == null) {
4521
+ idSet = new Set();
4522
+ idMap.set(current, idSet);
4523
+ }
4524
+ idSet.add(elt.id);
4525
+ current = current.parentElement;
4526
+ }
4175
4527
  }
4176
- idSet.add(elt.id);
4177
- current = current.parentElement;
4178
4528
  }
4179
- }
4180
- }
4181
4529
 
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
- }
4530
+ /**
4531
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
4532
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4533
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
4534
+ * to contribute to a parent nodes matching.
4535
+ *
4536
+ * @param {Element} oldContent the old content that will be morphed
4537
+ * @param {Element} newContent the new content to morph to
4538
+ * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4539
+ */
4540
+ function createIdMap(oldContent, newContent) {
4541
+ let idMap = new Map();
4542
+ populateIdMapForNode(oldContent, idMap);
4543
+ populateIdMapForNode(newContent, idMap);
4544
+ return idMap;
4545
+ }
4198
4546
 
4199
- //=============================================================================
4200
- // This is what ends up becoming the Idiomorph export
4201
- //=============================================================================
4202
- var idiomorph = { morph };
4547
+ //=============================================================================
4548
+ // This is what ends up becoming the Idiomorph global object
4549
+ //=============================================================================
4550
+ return {
4551
+ morph,
4552
+ defaults
4553
+ }
4554
+ })();
4203
4555
 
4204
4556
  class MorphRenderer extends Renderer {
4205
4557
  async render() {
@@ -4227,7 +4579,7 @@ Copyright © 2023 37signals LLC
4227
4579
  #morphElements(currentElement, newElement, morphStyle = "outerHTML") {
4228
4580
  this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement);
4229
4581
 
4230
- idiomorph.morph(currentElement, newElement, {
4582
+ Idiomorph.morph(currentElement, newElement, {
4231
4583
  morphStyle: morphStyle,
4232
4584
  callbacks: {
4233
4585
  beforeNodeAdded: this.#shouldAddElement,
@@ -4359,8 +4711,13 @@ Copyright © 2023 37signals LLC
4359
4711
  const mergedHeadElements = this.mergeProvisionalElements();
4360
4712
  const newStylesheetElements = this.copyNewHeadStylesheetElements();
4361
4713
  this.copyNewHeadScriptElements();
4714
+
4362
4715
  await mergedHeadElements;
4363
4716
  await newStylesheetElements;
4717
+
4718
+ if (this.willRender) {
4719
+ this.removeUnusedDynamicStylesheetElements();
4720
+ }
4364
4721
  }
4365
4722
 
4366
4723
  async replaceBody() {
@@ -4392,6 +4749,12 @@ Copyright © 2023 37signals LLC
4392
4749
  }
4393
4750
  }
4394
4751
 
4752
+ removeUnusedDynamicStylesheetElements() {
4753
+ for (const element of this.unusedDynamicStylesheetElements) {
4754
+ document.head.removeChild(element);
4755
+ }
4756
+ }
4757
+
4395
4758
  async mergeProvisionalElements() {
4396
4759
  const newHeadElements = [...this.newHeadProvisionalElements];
4397
4760
 
@@ -4457,6 +4820,16 @@ Copyright © 2023 37signals LLC
4457
4820
  await this.renderElement(this.currentElement, this.newElement);
4458
4821
  }
4459
4822
 
4823
+ get unusedDynamicStylesheetElements() {
4824
+ return this.oldHeadStylesheetElements.filter((element) => {
4825
+ return element.getAttribute("data-turbo-track") === "dynamic"
4826
+ })
4827
+ }
4828
+
4829
+ get oldHeadStylesheetElements() {
4830
+ return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
4831
+ }
4832
+
4460
4833
  get newHeadStylesheetElements() {
4461
4834
  return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
4462
4835
  }
@@ -4700,6 +5073,7 @@ Copyright © 2023 37signals LLC
4700
5073
 
4701
5074
  pageObserver = new PageObserver(this)
4702
5075
  cacheObserver = new CacheObserver()
5076
+ linkPrefetchObserver = new LinkPrefetchObserver(this, document)
4703
5077
  linkClickObserver = new LinkClickObserver(this, window)
4704
5078
  formSubmitObserver = new FormSubmitObserver(this, document)
4705
5079
  scrollObserver = new ScrollObserver(this)
@@ -4714,16 +5088,20 @@ Copyright © 2023 37signals LLC
4714
5088
  progressBarDelay = 500
4715
5089
  started = false
4716
5090
  formMode = "on"
5091
+ #pageRefreshDebouncePeriod = 150
4717
5092
 
4718
5093
  constructor(recentRequests) {
4719
5094
  this.recentRequests = recentRequests;
4720
5095
  this.preloader = new Preloader(this, this.view.snapshotCache);
5096
+ this.debouncedRefresh = this.refresh;
5097
+ this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
4721
5098
  }
4722
5099
 
4723
5100
  start() {
4724
5101
  if (!this.started) {
4725
5102
  this.pageObserver.start();
4726
5103
  this.cacheObserver.start();
5104
+ this.linkPrefetchObserver.start();
4727
5105
  this.formLinkClickObserver.start();
4728
5106
  this.linkClickObserver.start();
4729
5107
  this.formSubmitObserver.start();
@@ -4745,6 +5123,7 @@ Copyright © 2023 37signals LLC
4745
5123
  if (this.started) {
4746
5124
  this.pageObserver.stop();
4747
5125
  this.cacheObserver.stop();
5126
+ this.linkPrefetchObserver.stop();
4748
5127
  this.formLinkClickObserver.stop();
4749
5128
  this.linkClickObserver.stop();
4750
5129
  this.formSubmitObserver.stop();
@@ -4812,6 +5191,15 @@ Copyright © 2023 37signals LLC
4812
5191
  return this.history.restorationIdentifier
4813
5192
  }
4814
5193
 
5194
+ get pageRefreshDebouncePeriod() {
5195
+ return this.#pageRefreshDebouncePeriod
5196
+ }
5197
+
5198
+ set pageRefreshDebouncePeriod(value) {
5199
+ this.refresh = debounce(this.debouncedRefresh.bind(this), value);
5200
+ this.#pageRefreshDebouncePeriod = value;
5201
+ }
5202
+
4815
5203
  // Preloader delegate
4816
5204
 
4817
5205
  shouldPreloadLink(element) {
@@ -4861,6 +5249,15 @@ Copyright © 2023 37signals LLC
4861
5249
 
4862
5250
  submittedFormLinkToLocation() {}
4863
5251
 
5252
+ // Link hover observer delegate
5253
+
5254
+ canPrefetchRequestToLocation(link, location) {
5255
+ return (
5256
+ this.elementIsNavigatable(link) &&
5257
+ locationIsVisitable(location, this.snapshot.rootLocation)
5258
+ )
5259
+ }
5260
+
4864
5261
  // Link click observer delegate
4865
5262
 
4866
5263
  willFollowLinkToLocation(link, location, event) {
@@ -4960,8 +5357,8 @@ Copyright © 2023 37signals LLC
4960
5357
  }
4961
5358
  }
4962
5359
 
4963
- allowsImmediateRender({ element }, isPreview, options) {
4964
- const event = this.notifyApplicationBeforeRender(element, isPreview, options);
5360
+ allowsImmediateRender({ element }, options) {
5361
+ const event = this.notifyApplicationBeforeRender(element, options);
4965
5362
  const {
4966
5363
  defaultPrevented,
4967
5364
  detail: { render }
@@ -4974,9 +5371,9 @@ Copyright © 2023 37signals LLC
4974
5371
  return !defaultPrevented
4975
5372
  }
4976
5373
 
4977
- viewRenderedSnapshot(_snapshot, isPreview, renderMethod) {
5374
+ viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) {
4978
5375
  this.view.lastRenderedLocation = this.history.location;
4979
- this.notifyApplicationAfterRender(isPreview, renderMethod);
5376
+ this.notifyApplicationAfterRender(renderMethod);
4980
5377
  }
4981
5378
 
4982
5379
  preloadOnLoadLinksForView(element) {
@@ -5032,15 +5429,15 @@ Copyright © 2023 37signals LLC
5032
5429
  return dispatch("turbo:before-cache")
5033
5430
  }
5034
5431
 
5035
- notifyApplicationBeforeRender(newBody, isPreview, options) {
5432
+ notifyApplicationBeforeRender(newBody, options) {
5036
5433
  return dispatch("turbo:before-render", {
5037
- detail: { newBody, isPreview, ...options },
5434
+ detail: { newBody, ...options },
5038
5435
  cancelable: true
5039
5436
  })
5040
5437
  }
5041
5438
 
5042
- notifyApplicationAfterRender(isPreview, renderMethod) {
5043
- return dispatch("turbo:render", { detail: { isPreview, renderMethod } })
5439
+ notifyApplicationAfterRender(renderMethod) {
5440
+ return dispatch("turbo:render", { detail: { renderMethod } })
5044
5441
  }
5045
5442
 
5046
5443
  notifyApplicationAfterPageLoad(timing = {}) {
@@ -5500,7 +5897,7 @@ Copyright © 2023 37signals LLC
5500
5897
 
5501
5898
  // View delegate
5502
5899
 
5503
- allowsImmediateRender({ element: newFrame }, _isPreview, options) {
5900
+ allowsImmediateRender({ element: newFrame }, options) {
5504
5901
  const event = dispatch("turbo:before-frame-render", {
5505
5902
  target: this.element,
5506
5903
  detail: { newFrame, ...options },