@hotwired/turbo 8.0.0-beta.1 → 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.1
3
- Copyright © 2023 37signals LLC
2
+ Turbo 8.0.0-beta.3
3
+ Copyright © 2024 37signals LLC
4
4
  */
5
5
  (function (global, factory) {
6
6
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
@@ -639,13 +639,61 @@ Copyright © 2023 37signals LLC
639
639
  return [before, after]
640
640
  }
641
641
 
642
- function fetch(url, options = {}) {
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
+
670
+ class LimitedSet extends Set {
671
+ constructor(maxSize) {
672
+ super();
673
+ this.maxSize = maxSize;
674
+ }
675
+
676
+ add(value) {
677
+ if (this.size >= this.maxSize) {
678
+ const iterator = this.values();
679
+ const oldestValue = iterator.next().value;
680
+ this.delete(oldestValue);
681
+ }
682
+ super.add(value);
683
+ }
684
+ }
685
+
686
+ const recentRequests = new LimitedSet(20);
687
+
688
+ const nativeFetch = window.fetch;
689
+
690
+ function fetchWithTurboHeaders(url, options = {}) {
643
691
  const modifiedHeaders = new Headers(options.headers || {});
644
692
  const requestUID = uuid();
645
- window.Turbo.session.recentRequests.add(requestUID);
693
+ recentRequests.add(requestUID);
646
694
  modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
647
695
 
648
- return window.fetch(url, {
696
+ return nativeFetch(url, {
649
697
  ...options,
650
698
  headers: modifiedHeaders
651
699
  })
@@ -769,10 +817,17 @@ Copyright © 2023 37signals LLC
769
817
  async perform() {
770
818
  const { fetchOptions } = this;
771
819
  this.delegate.prepareRequest(this);
772
- await this.#allowRequestToBeIntercepted(fetchOptions);
820
+ const event = await this.#allowRequestToBeIntercepted(fetchOptions);
773
821
  try {
774
822
  this.delegate.requestStarted(this);
775
- const response = await fetch(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;
776
831
  return await this.receive(response)
777
832
  } catch (error) {
778
833
  if (error.name !== "AbortError") {
@@ -834,6 +889,8 @@ Copyright © 2023 37signals LLC
834
889
  });
835
890
  this.url = event.detail.url;
836
891
  if (event.defaultPrevented) await requestInterception;
892
+
893
+ return event
837
894
  }
838
895
 
839
896
  #willDelegateErrorHandling(error) {
@@ -944,6 +1001,41 @@ Copyright © 2023 37signals LLC
944
1001
  return fragment
945
1002
  }
946
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
+
947
1039
  const FormSubmissionState = {
948
1040
  initialized: "initialized",
949
1041
  requesting: "requesting",
@@ -1052,6 +1144,7 @@ Copyright © 2023 37signals LLC
1052
1144
  this.state = FormSubmissionState.waiting;
1053
1145
  this.submitter?.setAttribute("disabled", "");
1054
1146
  this.setSubmitsWith();
1147
+ markAsBusy(this.formElement);
1055
1148
  dispatch("turbo:submit-start", {
1056
1149
  target: this.formElement,
1057
1150
  detail: { formSubmission: this }
@@ -1060,13 +1153,20 @@ Copyright © 2023 37signals LLC
1060
1153
  }
1061
1154
 
1062
1155
  requestPreventedHandlingResponse(request, response) {
1156
+ prefetchCache.clear();
1157
+
1063
1158
  this.result = { success: response.succeeded, fetchResponse: response };
1064
1159
  }
1065
1160
 
1066
1161
  requestSucceededWithResponse(request, response) {
1067
1162
  if (response.clientError || response.serverError) {
1068
1163
  this.delegate.formSubmissionFailedWithResponse(this, response);
1069
- } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1164
+ return
1165
+ }
1166
+
1167
+ prefetchCache.clear();
1168
+
1169
+ if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1070
1170
  const error = new Error("Form responses must redirect to another location");
1071
1171
  this.delegate.formSubmissionErrored(this, error);
1072
1172
  } else {
@@ -1090,6 +1190,7 @@ Copyright © 2023 37signals LLC
1090
1190
  this.state = FormSubmissionState.stopped;
1091
1191
  this.submitter?.removeAttribute("disabled");
1092
1192
  this.resetSubmitterText();
1193
+ clearBusyState(this.formElement);
1093
1194
  dispatch("turbo:submit-end", {
1094
1195
  target: this.formElement,
1095
1196
  detail: { formSubmission: this, ...this.result }
@@ -1383,7 +1484,7 @@ Copyright © 2023 37signals LLC
1383
1484
 
1384
1485
  const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
1385
1486
  const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement };
1386
- const immediateRender = this.delegate.allowsImmediateRender(snapshot, isPreview, options);
1487
+ const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
1387
1488
  if (!immediateRender) await renderInterception;
1388
1489
 
1389
1490
  await this.renderSnapshot(renderer);
@@ -1417,6 +1518,14 @@ Copyright © 2023 37signals LLC
1417
1518
  }
1418
1519
  }
1419
1520
 
1521
+ markVisitDirection(direction) {
1522
+ this.element.setAttribute("data-turbo-visit-direction", direction);
1523
+ }
1524
+
1525
+ unmarkVisitDirection() {
1526
+ this.element.removeAttribute("data-turbo-visit-direction");
1527
+ }
1528
+
1420
1529
  async renderSnapshot(renderer) {
1421
1530
  await renderer.render();
1422
1531
  }
@@ -1513,9 +1622,9 @@ Copyright © 2023 37signals LLC
1513
1622
  clickBubbled = (event) => {
1514
1623
  if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
1515
1624
  const target = (event.composedPath && event.composedPath()[0]) || event.target;
1516
- const link = this.findLinkFromClickTarget(target);
1625
+ const link = findLinkFromClickTarget(target);
1517
1626
  if (link && doesNotTargetIFrame(link)) {
1518
- const location = this.getLocationForLink(link);
1627
+ const location = getLocationForLink(link);
1519
1628
  if (this.delegate.willFollowLinkToLocation(link, location, event)) {
1520
1629
  event.preventDefault();
1521
1630
  this.delegate.followedLinkToLocation(link, location);
@@ -1535,26 +1644,6 @@ Copyright © 2023 37signals LLC
1535
1644
  event.shiftKey
1536
1645
  )
1537
1646
  }
1538
-
1539
- findLinkFromClickTarget(target) {
1540
- return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
1541
- }
1542
-
1543
- getLocationForLink(link) {
1544
- return expandURL(link.getAttribute("href") || "")
1545
- }
1546
- }
1547
-
1548
- function doesNotTargetIFrame(anchor) {
1549
- if (anchor.hasAttribute("target")) {
1550
- for (const element of document.getElementsByName(anchor.target)) {
1551
- if (element instanceof HTMLIFrameElement) return false
1552
- }
1553
-
1554
- return true
1555
- } else {
1556
- return true
1557
- }
1558
1647
  }
1559
1648
 
1560
1649
  class FormLinkClickObserver {
@@ -1571,6 +1660,16 @@ Copyright © 2023 37signals LLC
1571
1660
  this.linkInterceptor.stop();
1572
1661
  }
1573
1662
 
1663
+ // Link hover observer delegate
1664
+
1665
+ canPrefetchRequestToLocation(link, location) {
1666
+ return false
1667
+ }
1668
+
1669
+ prefetchAndCacheRequestToLocation(link, location) {
1670
+ return
1671
+ }
1672
+
1574
1673
  // Link click observer delegate
1575
1674
 
1576
1675
  willFollowLinkToLocation(link, location, originalEvent) {
@@ -1786,14 +1885,14 @@ Copyright © 2023 37signals LLC
1786
1885
  }
1787
1886
 
1788
1887
  async render() {
1789
- await nextAnimationFrame();
1888
+ await nextRepaint();
1790
1889
  this.preservingPermanentElements(() => {
1791
1890
  this.loadFrameElement();
1792
1891
  });
1793
1892
  this.scrollFrameIntoView();
1794
- await nextAnimationFrame();
1893
+ await nextRepaint();
1795
1894
  this.focusFirstAutofocusableElement();
1796
- await nextAnimationFrame();
1895
+ await nextRepaint();
1797
1896
  this.activateScriptElements();
1798
1897
  }
1799
1898
 
@@ -1844,6 +1943,8 @@ Copyright © 2023 37signals LLC
1844
1943
  }
1845
1944
  }
1846
1945
 
1946
+ const ProgressBarID = "turbo-progress-bar";
1947
+
1847
1948
  class ProgressBar {
1848
1949
  static animationDuration = 300 /*ms*/
1849
1950
 
@@ -1948,6 +2049,8 @@ Copyright © 2023 37signals LLC
1948
2049
 
1949
2050
  createStylesheetElement() {
1950
2051
  const element = document.createElement("style");
2052
+ element.id = ProgressBarID;
2053
+ element.setAttribute("data-turbo-permanent", "");
1951
2054
  element.type = "text/css";
1952
2055
  element.textContent = ProgressBar.defaultCSS;
1953
2056
  if (this.cspNonce) {
@@ -2219,6 +2322,12 @@ Copyright © 2023 37signals LLC
2219
2322
  contentTypeMismatch: -2
2220
2323
  };
2221
2324
 
2325
+ const Direction = {
2326
+ advance: "forward",
2327
+ restore: "back",
2328
+ replace: "none"
2329
+ };
2330
+
2222
2331
  class Visit {
2223
2332
  identifier = uuid() // Required by turbo-ios
2224
2333
  timingMetrics = {}
@@ -2248,7 +2357,8 @@ Copyright © 2023 37signals LLC
2248
2357
  willRender,
2249
2358
  updateHistory,
2250
2359
  shouldCacheSnapshot,
2251
- acceptsStreamResponse
2360
+ acceptsStreamResponse,
2361
+ direction
2252
2362
  } = {
2253
2363
  ...defaultOptions,
2254
2364
  ...options
@@ -2266,6 +2376,7 @@ Copyright © 2023 37signals LLC
2266
2376
  this.scrolled = !willRender;
2267
2377
  this.shouldCacheSnapshot = shouldCacheSnapshot;
2268
2378
  this.acceptsStreamResponse = acceptsStreamResponse;
2379
+ this.direction = direction || Direction[action];
2269
2380
  }
2270
2381
 
2271
2382
  get adapter() {
@@ -2518,7 +2629,7 @@ Copyright © 2023 37signals LLC
2518
2629
  // Scrolling
2519
2630
 
2520
2631
  performScroll() {
2521
- if (!this.scrolled && !this.view.forceReloaded && !this.view.snapshot.shouldPreserveScrollPosition) {
2632
+ if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) {
2522
2633
  if (this.action == "restore") {
2523
2634
  this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop();
2524
2635
  } else {
@@ -2593,9 +2704,7 @@ Copyright © 2023 37signals LLC
2593
2704
 
2594
2705
  async render(callback) {
2595
2706
  this.cancelRender();
2596
- await new Promise((resolve) => {
2597
- this.frame = requestAnimationFrame(() => resolve());
2598
- });
2707
+ this.frame = await nextRepaint();
2599
2708
  await callback();
2600
2709
  delete this.frame;
2601
2710
  }
@@ -2873,6 +2982,7 @@ Copyright © 2023 37signals LLC
2873
2982
  restorationData = {}
2874
2983
  started = false
2875
2984
  pageLoaded = false
2985
+ currentIndex = 0
2876
2986
 
2877
2987
  constructor(delegate) {
2878
2988
  this.delegate = delegate;
@@ -2882,6 +2992,7 @@ Copyright © 2023 37signals LLC
2882
2992
  if (!this.started) {
2883
2993
  addEventListener("popstate", this.onPopState, false);
2884
2994
  addEventListener("load", this.onPageLoad, false);
2995
+ this.currentIndex = history.state?.turbo?.restorationIndex || 0;
2885
2996
  this.started = true;
2886
2997
  this.replace(new URL(window.location.href));
2887
2998
  }
@@ -2904,7 +3015,9 @@ Copyright © 2023 37signals LLC
2904
3015
  }
2905
3016
 
2906
3017
  update(method, location, restorationIdentifier = uuid()) {
2907
- const state = { turbo: { restorationIdentifier } };
3018
+ if (method === history.pushState) ++this.currentIndex;
3019
+
3020
+ const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } };
2908
3021
  method.call(history, state, "", location.href);
2909
3022
  this.location = location;
2910
3023
  this.restorationIdentifier = restorationIdentifier;
@@ -2948,9 +3061,11 @@ Copyright © 2023 37signals LLC
2948
3061
  const { turbo } = event.state || {};
2949
3062
  if (turbo) {
2950
3063
  this.location = new URL(window.location.href);
2951
- const { restorationIdentifier } = turbo;
3064
+ const { restorationIdentifier, restorationIndex } = turbo;
2952
3065
  this.restorationIdentifier = restorationIdentifier;
2953
- this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier);
3066
+ const direction = restorationIndex > this.currentIndex ? "forward" : "back";
3067
+ this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
3068
+ this.currentIndex = restorationIndex;
2954
3069
  }
2955
3070
  }
2956
3071
  }
@@ -2972,6 +3087,176 @@ Copyright © 2023 37signals LLC
2972
3087
  }
2973
3088
  }
2974
3089
 
3090
+ class LinkPrefetchObserver {
3091
+ started = false
3092
+ hoverTriggerEvent = "mouseenter"
3093
+ touchTriggerEvent = "touchstart"
3094
+
3095
+ constructor(delegate, eventTarget) {
3096
+ this.delegate = delegate;
3097
+ this.eventTarget = eventTarget;
3098
+ }
3099
+
3100
+ start() {
3101
+ if (this.started) return
3102
+
3103
+ if (this.eventTarget.readyState === "loading") {
3104
+ this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true });
3105
+ } else {
3106
+ this.#enable();
3107
+ }
3108
+ }
3109
+
3110
+ stop() {
3111
+ if (!this.started) return
3112
+
3113
+ this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3114
+ capture: true,
3115
+ passive: true
3116
+ });
3117
+ this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3118
+ capture: true,
3119
+ passive: true
3120
+ });
3121
+ this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3122
+ this.started = false;
3123
+ }
3124
+
3125
+ #enable = () => {
3126
+ this.eventTarget.addEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3127
+ capture: true,
3128
+ passive: true
3129
+ });
3130
+ this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3131
+ capture: true,
3132
+ passive: true
3133
+ });
3134
+ this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3135
+ this.started = true;
3136
+ }
3137
+
3138
+ #tryToPrefetchRequest = (event) => {
3139
+ if (getMetaContent("turbo-prefetch") !== "true") return
3140
+
3141
+ const target = event.target;
3142
+ const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])");
3143
+
3144
+ if (isLink && this.#isPrefetchable(target)) {
3145
+ const link = target;
3146
+ const location = getLocationForLink(link);
3147
+
3148
+ if (this.delegate.canPrefetchRequestToLocation(link, location)) {
3149
+ const fetchRequest = new FetchRequest(
3150
+ this,
3151
+ FetchMethod.get,
3152
+ location,
3153
+ new URLSearchParams(),
3154
+ target
3155
+ );
3156
+
3157
+ prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
3158
+
3159
+ link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true });
3160
+ }
3161
+ }
3162
+ }
3163
+
3164
+ #tryToUsePrefetchedRequest = (event) => {
3165
+ if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") {
3166
+ const cached = prefetchCache.get(event.detail.url.toString());
3167
+
3168
+ if (cached) {
3169
+ // User clicked link, use cache response
3170
+ event.detail.fetchRequest = cached;
3171
+ }
3172
+
3173
+ prefetchCache.clear();
3174
+ }
3175
+ }
3176
+
3177
+ prepareRequest(request) {
3178
+ const link = request.target;
3179
+
3180
+ request.headers["Sec-Purpose"] = "prefetch";
3181
+
3182
+ if (link.dataset.turboFrame && link.dataset.turboFrame !== "_top") {
3183
+ request.headers["Turbo-Frame"] = link.dataset.turboFrame;
3184
+ } else if (link.dataset.turboFrame !== "_top") {
3185
+ const turboFrame = link.closest("turbo-frame");
3186
+
3187
+ if (turboFrame) {
3188
+ request.headers["Turbo-Frame"] = turboFrame.id;
3189
+ }
3190
+ }
3191
+
3192
+ if (link.hasAttribute("data-turbo-stream")) {
3193
+ request.acceptResponseType("text/vnd.turbo-stream.html");
3194
+ }
3195
+ }
3196
+
3197
+ // Fetch request interface
3198
+
3199
+ requestSucceededWithResponse() {}
3200
+
3201
+ requestStarted(fetchRequest) {}
3202
+
3203
+ requestErrored(fetchRequest) {}
3204
+
3205
+ requestFinished(fetchRequest) {}
3206
+
3207
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
3208
+
3209
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
3210
+
3211
+ get #cacheTtl() {
3212
+ return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
3213
+ }
3214
+
3215
+ #isPrefetchable(link) {
3216
+ const href = link.getAttribute("href");
3217
+
3218
+ if (!href || href === "#" || link.dataset.turbo === "false" || link.dataset.turboPrefetch === "false") {
3219
+ return false
3220
+ }
3221
+
3222
+ if (link.origin !== document.location.origin) {
3223
+ return false
3224
+ }
3225
+
3226
+ if (!["http:", "https:"].includes(link.protocol)) {
3227
+ return false
3228
+ }
3229
+
3230
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3231
+ return false
3232
+ }
3233
+
3234
+ if (link.dataset.turboMethod && link.dataset.turboMethod !== "get") {
3235
+ return false
3236
+ }
3237
+
3238
+ if (targetsIframe(link)) {
3239
+ return false
3240
+ }
3241
+
3242
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3243
+ return false
3244
+ }
3245
+
3246
+ const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]");
3247
+
3248
+ if (turboPrefetchParent && turboPrefetchParent.dataset.turboPrefetch === "false") {
3249
+ return false
3250
+ }
3251
+
3252
+ return true
3253
+ }
3254
+ }
3255
+
3256
+ const targetsIframe = (link) => {
3257
+ return !doesNotTargetIFrame(link)
3258
+ };
3259
+
2975
3260
  class Navigator {
2976
3261
  constructor(delegate) {
2977
3262
  this.delegate = delegate;
@@ -3287,7 +3572,7 @@ Copyright © 2023 37signals LLC
3287
3572
  }
3288
3573
 
3289
3574
  callback();
3290
- await nextAnimationFrame();
3575
+ await nextRepaint();
3291
3576
 
3292
3577
  const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body;
3293
3578
 
@@ -3442,722 +3727,838 @@ Copyright © 2023 37signals LLC
3442
3727
  }
3443
3728
  }
3444
3729
 
3445
- let EMPTY_SET = new Set();
3730
+ // base IIFE to define idiomorph
3731
+ var Idiomorph = (function () {
3446
3732
 
3447
- //=============================================================================
3448
- // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3449
- //=============================================================================
3450
- function morph(oldNode, newContent, config = {}) {
3733
+ //=============================================================================
3734
+ // AND NOW IT BEGINS...
3735
+ //=============================================================================
3736
+ let EMPTY_SET = new Set();
3451
3737
 
3452
- if (oldNode instanceof Document) {
3453
- oldNode = oldNode.documentElement;
3454
- }
3455
-
3456
- if (typeof newContent === 'string') {
3457
- newContent = parseContent(newContent);
3458
- }
3459
-
3460
- let normalizedContent = normalizeContent(newContent);
3461
-
3462
- let ctx = createMorphContext(oldNode, normalizedContent, config);
3463
-
3464
- return morphNormalizedContent(oldNode, normalizedContent, ctx);
3465
- }
3738
+ // default configuration values, updatable by users now
3739
+ let defaults = {
3740
+ morphStyle: "outerHTML",
3741
+ callbacks : {
3742
+ beforeNodeAdded: noOp,
3743
+ afterNodeAdded: noOp,
3744
+ beforeNodeMorphed: noOp,
3745
+ afterNodeMorphed: noOp,
3746
+ beforeNodeRemoved: noOp,
3747
+ afterNodeRemoved: noOp,
3748
+ beforeAttributeUpdated: noOp,
3466
3749
 
3467
- function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3468
- if (ctx.head.block) {
3469
- let oldHead = oldNode.querySelector('head');
3470
- let newHead = normalizedNewContent.querySelector('head');
3471
- if (oldHead && newHead) {
3472
- let promises = handleHeadElement(newHead, oldHead, ctx);
3473
- // when head promises resolve, call morph again, ignoring the head tag
3474
- Promise.all(promises).then(function () {
3475
- morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3476
- head: {
3477
- block: false,
3478
- ignore: true
3479
- }
3480
- }));
3481
- });
3482
- return;
3483
- }
3484
- }
3750
+ },
3751
+ head: {
3752
+ style: 'merge',
3753
+ shouldPreserve: function (elt) {
3754
+ return elt.getAttribute("im-preserve") === "true";
3755
+ },
3756
+ shouldReAppend: function (elt) {
3757
+ return elt.getAttribute("im-re-append") === "true";
3758
+ },
3759
+ shouldRemove: noOp,
3760
+ afterHeadMorphed: noOp,
3761
+ }
3762
+ };
3485
3763
 
3486
- if (ctx.morphStyle === "innerHTML") {
3764
+ //=============================================================================
3765
+ // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3766
+ //=============================================================================
3767
+ function morph(oldNode, newContent, config = {}) {
3487
3768
 
3488
- // innerHTML, so we are only updating the children
3489
- morphChildren(normalizedNewContent, oldNode, ctx);
3490
- return oldNode.children;
3769
+ if (oldNode instanceof Document) {
3770
+ oldNode = oldNode.documentElement;
3771
+ }
3491
3772
 
3492
- } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3493
- // otherwise find the best element match in the new content, morph that, and merge its siblings
3494
- // into either side of the best match
3495
- let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3773
+ if (typeof newContent === 'string') {
3774
+ newContent = parseContent(newContent);
3775
+ }
3496
3776
 
3497
- // stash the siblings that will need to be inserted on either side of the best match
3498
- let previousSibling = bestMatch?.previousSibling;
3499
- let nextSibling = bestMatch?.nextSibling;
3777
+ let normalizedContent = normalizeContent(newContent);
3500
3778
 
3501
- // morph it
3502
- let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3779
+ let ctx = createMorphContext(oldNode, normalizedContent, config);
3503
3780
 
3504
- if (bestMatch) {
3505
- // if there was a best match, merge the siblings in too and return the
3506
- // whole bunch
3507
- return insertSiblings(previousSibling, morphedNode, nextSibling);
3508
- } else {
3509
- // otherwise nothing was added to the DOM
3510
- return []
3781
+ return morphNormalizedContent(oldNode, normalizedContent, ctx);
3511
3782
  }
3512
- } else {
3513
- throw "Do not understand how to morph style " + ctx.morphStyle;
3514
- }
3515
- }
3516
-
3517
3783
 
3784
+ function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3785
+ if (ctx.head.block) {
3786
+ let oldHead = oldNode.querySelector('head');
3787
+ let newHead = normalizedNewContent.querySelector('head');
3788
+ if (oldHead && newHead) {
3789
+ let promises = handleHeadElement(newHead, oldHead, ctx);
3790
+ // when head promises resolve, call morph again, ignoring the head tag
3791
+ Promise.all(promises).then(function () {
3792
+ morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3793
+ head: {
3794
+ block: false,
3795
+ ignore: true
3796
+ }
3797
+ }));
3798
+ });
3799
+ return;
3800
+ }
3801
+ }
3518
3802
 
3519
- /**
3520
- * @param oldNode root node to merge content into
3521
- * @param newContent new content to merge
3522
- * @param ctx the merge context
3523
- * @returns {Element} the element that ended up in the DOM
3524
- */
3525
- function morphOldNodeTo(oldNode, newContent, ctx) {
3526
- if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3527
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return;
3528
-
3529
- oldNode.remove();
3530
- ctx.callbacks.afterNodeRemoved(oldNode);
3531
- return null;
3532
- } else if (!isSoftMatch(oldNode, newContent)) {
3533
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return;
3534
- if (ctx.callbacks.beforeNodeAdded(newContent) === false) return;
3535
-
3536
- oldNode.parentElement.replaceChild(newContent, oldNode);
3537
- ctx.callbacks.afterNodeAdded(newContent);
3538
- ctx.callbacks.afterNodeRemoved(oldNode);
3539
- return newContent;
3540
- } else {
3541
- if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return;
3803
+ if (ctx.morphStyle === "innerHTML") {
3542
3804
 
3543
- if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3544
- handleHeadElement(newContent, oldNode, ctx);
3545
- } else {
3546
- syncNodeFrom(newContent, oldNode);
3547
- morphChildren(newContent, oldNode, ctx);
3548
- }
3549
- ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3550
- return oldNode;
3551
- }
3552
- }
3805
+ // innerHTML, so we are only updating the children
3806
+ morphChildren(normalizedNewContent, oldNode, ctx);
3807
+ return oldNode.children;
3553
3808
 
3554
- /**
3555
- * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3556
- * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3557
- * by using id sets, we are able to better match up with content deeper in the DOM.
3558
- *
3559
- * Basic algorithm is, for each node in the new content:
3560
- *
3561
- * - if we have reached the end of the old parent, append the new content
3562
- * - if the new content has an id set match with the current insertion point, morph
3563
- * - search for an id set match
3564
- * - if id set match found, morph
3565
- * - otherwise search for a "soft" match
3566
- * - if a soft match is found, morph
3567
- * - otherwise, prepend the new node before the current insertion point
3568
- *
3569
- * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3570
- * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3571
- *
3572
- * @param {Element} newParent the parent element of the new content
3573
- * @param {Element } oldParent the old content that we are merging the new content into
3574
- * @param ctx the merge context
3575
- */
3576
- function morphChildren(newParent, oldParent, ctx) {
3809
+ } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3810
+ // otherwise find the best element match in the new content, morph that, and merge its siblings
3811
+ // into either side of the best match
3812
+ let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3577
3813
 
3578
- let nextNewChild = newParent.firstChild;
3579
- let insertionPoint = oldParent.firstChild;
3580
- let newChild;
3814
+ // stash the siblings that will need to be inserted on either side of the best match
3815
+ let previousSibling = bestMatch?.previousSibling;
3816
+ let nextSibling = bestMatch?.nextSibling;
3581
3817
 
3582
- // run through all the new content
3583
- while (nextNewChild) {
3818
+ // morph it
3819
+ let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3584
3820
 
3585
- newChild = nextNewChild;
3586
- nextNewChild = newChild.nextSibling;
3821
+ if (bestMatch) {
3822
+ // if there was a best match, merge the siblings in too and return the
3823
+ // whole bunch
3824
+ return insertSiblings(previousSibling, morphedNode, nextSibling);
3825
+ } else {
3826
+ // otherwise nothing was added to the DOM
3827
+ return []
3828
+ }
3829
+ } else {
3830
+ throw "Do not understand how to morph style " + ctx.morphStyle;
3831
+ }
3832
+ }
3587
3833
 
3588
- // if we are at the end of the exiting parent's children, just append
3589
- if (insertionPoint == null) {
3590
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3591
3834
 
3592
- oldParent.appendChild(newChild);
3593
- ctx.callbacks.afterNodeAdded(newChild);
3594
- removeIdsFromConsideration(ctx, newChild);
3595
- continue;
3835
+ /**
3836
+ * @param possibleActiveElement
3837
+ * @param ctx
3838
+ * @returns {boolean}
3839
+ */
3840
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
3841
+ return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
3596
3842
  }
3597
3843
 
3598
- // if the current node has an id set match then morph
3599
- if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3600
- morphOldNodeTo(insertionPoint, newChild, ctx);
3601
- insertionPoint = insertionPoint.nextSibling;
3602
- removeIdsFromConsideration(ctx, newChild);
3603
- continue;
3844
+ /**
3845
+ * @param oldNode root node to merge content into
3846
+ * @param newContent new content to merge
3847
+ * @param ctx the merge context
3848
+ * @returns {Element} the element that ended up in the DOM
3849
+ */
3850
+ function morphOldNodeTo(oldNode, newContent, ctx) {
3851
+ if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3852
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3853
+
3854
+ oldNode.remove();
3855
+ ctx.callbacks.afterNodeRemoved(oldNode);
3856
+ return null;
3857
+ } else if (!isSoftMatch(oldNode, newContent)) {
3858
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3859
+ if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
3860
+
3861
+ oldNode.parentElement.replaceChild(newContent, oldNode);
3862
+ ctx.callbacks.afterNodeAdded(newContent);
3863
+ ctx.callbacks.afterNodeRemoved(oldNode);
3864
+ return newContent;
3865
+ } else {
3866
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
3867
+
3868
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3869
+ handleHeadElement(newContent, oldNode, ctx);
3870
+ } else {
3871
+ syncNodeFrom(newContent, oldNode, ctx);
3872
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
3873
+ morphChildren(newContent, oldNode, ctx);
3874
+ }
3875
+ }
3876
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3877
+ return oldNode;
3878
+ }
3604
3879
  }
3605
3880
 
3606
- // otherwise search forward in the existing old children for an id set match
3607
- let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3881
+ /**
3882
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3883
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3884
+ * by using id sets, we are able to better match up with content deeper in the DOM.
3885
+ *
3886
+ * Basic algorithm is, for each node in the new content:
3887
+ *
3888
+ * - if we have reached the end of the old parent, append the new content
3889
+ * - if the new content has an id set match with the current insertion point, morph
3890
+ * - search for an id set match
3891
+ * - if id set match found, morph
3892
+ * - otherwise search for a "soft" match
3893
+ * - if a soft match is found, morph
3894
+ * - otherwise, prepend the new node before the current insertion point
3895
+ *
3896
+ * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3897
+ * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3898
+ *
3899
+ * @param {Element} newParent the parent element of the new content
3900
+ * @param {Element } oldParent the old content that we are merging the new content into
3901
+ * @param ctx the merge context
3902
+ */
3903
+ function morphChildren(newParent, oldParent, ctx) {
3904
+
3905
+ let nextNewChild = newParent.firstChild;
3906
+ let insertionPoint = oldParent.firstChild;
3907
+ let newChild;
3908
+
3909
+ // run through all the new content
3910
+ while (nextNewChild) {
3911
+
3912
+ newChild = nextNewChild;
3913
+ nextNewChild = newChild.nextSibling;
3914
+
3915
+ // if we are at the end of the exiting parent's children, just append
3916
+ if (insertionPoint == null) {
3917
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3918
+
3919
+ oldParent.appendChild(newChild);
3920
+ ctx.callbacks.afterNodeAdded(newChild);
3921
+ removeIdsFromConsideration(ctx, newChild);
3922
+ continue;
3923
+ }
3608
3924
 
3609
- // if we found a potential match, remove the nodes until that point and morph
3610
- if (idSetMatch) {
3611
- insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3612
- morphOldNodeTo(idSetMatch, newChild, ctx);
3613
- removeIdsFromConsideration(ctx, newChild);
3614
- continue;
3615
- }
3925
+ // if the current node has an id set match then morph
3926
+ if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3927
+ morphOldNodeTo(insertionPoint, newChild, ctx);
3928
+ insertionPoint = insertionPoint.nextSibling;
3929
+ removeIdsFromConsideration(ctx, newChild);
3930
+ continue;
3931
+ }
3616
3932
 
3617
- // no id set match found, so scan forward for a soft match for the current node
3618
- let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3933
+ // otherwise search forward in the existing old children for an id set match
3934
+ let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3619
3935
 
3620
- // if we found a soft match for the current node, morph
3621
- if (softMatch) {
3622
- insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3623
- morphOldNodeTo(softMatch, newChild, ctx);
3624
- removeIdsFromConsideration(ctx, newChild);
3625
- continue;
3626
- }
3936
+ // if we found a potential match, remove the nodes until that point and morph
3937
+ if (idSetMatch) {
3938
+ insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3939
+ morphOldNodeTo(idSetMatch, newChild, ctx);
3940
+ removeIdsFromConsideration(ctx, newChild);
3941
+ continue;
3942
+ }
3627
3943
 
3628
- // abandon all hope of morphing, just insert the new child before the insertion point
3629
- // and move on
3630
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3944
+ // no id set match found, so scan forward for a soft match for the current node
3945
+ let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3631
3946
 
3632
- oldParent.insertBefore(newChild, insertionPoint);
3633
- ctx.callbacks.afterNodeAdded(newChild);
3634
- removeIdsFromConsideration(ctx, newChild);
3635
- }
3947
+ // if we found a soft match for the current node, morph
3948
+ if (softMatch) {
3949
+ insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3950
+ morphOldNodeTo(softMatch, newChild, ctx);
3951
+ removeIdsFromConsideration(ctx, newChild);
3952
+ continue;
3953
+ }
3636
3954
 
3637
- // remove any remaining old nodes that didn't match up with new content
3638
- while (insertionPoint !== null) {
3955
+ // abandon all hope of morphing, just insert the new child before the insertion point
3956
+ // and move on
3957
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3639
3958
 
3640
- let tempNode = insertionPoint;
3641
- insertionPoint = insertionPoint.nextSibling;
3642
- removeNode(tempNode, ctx);
3643
- }
3644
- }
3959
+ oldParent.insertBefore(newChild, insertionPoint);
3960
+ ctx.callbacks.afterNodeAdded(newChild);
3961
+ removeIdsFromConsideration(ctx, newChild);
3962
+ }
3645
3963
 
3646
- //=============================================================================
3647
- // Attribute Syncing Code
3648
- //=============================================================================
3964
+ // remove any remaining old nodes that didn't match up with new content
3965
+ while (insertionPoint !== null) {
3649
3966
 
3650
- /**
3651
- * syncs a given node with another node, copying over all attributes and
3652
- * inner element state from the 'from' node to the 'to' node
3653
- *
3654
- * @param {Element} from the element to copy attributes & state from
3655
- * @param {Element} to the element to copy attributes & state to
3656
- */
3657
- function syncNodeFrom(from, to) {
3658
- let type = from.nodeType;
3659
-
3660
- // if is an element type, sync the attributes from the
3661
- // new node into the new node
3662
- if (type === 1 /* element type */) {
3663
- const fromAttributes = from.attributes;
3664
- const toAttributes = to.attributes;
3665
- for (const fromAttribute of fromAttributes) {
3666
- if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
3667
- to.setAttribute(fromAttribute.name, fromAttribute.value);
3967
+ let tempNode = insertionPoint;
3968
+ insertionPoint = insertionPoint.nextSibling;
3969
+ removeNode(tempNode, ctx);
3668
3970
  }
3669
3971
  }
3670
- for (const toAttribute of toAttributes) {
3671
- if (!from.hasAttribute(toAttribute.name)) {
3672
- to.removeAttribute(toAttribute.name);
3972
+
3973
+ //=============================================================================
3974
+ // Attribute Syncing Code
3975
+ //=============================================================================
3976
+
3977
+ /**
3978
+ * @param attr {String} the attribute to be mutated
3979
+ * @param to {Element} the element that is going to be updated
3980
+ * @param updateType {("update"|"remove")}
3981
+ * @param ctx the merge context
3982
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
3983
+ */
3984
+ function ignoreAttribute(attr, to, updateType, ctx) {
3985
+ if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
3986
+ return true;
3673
3987
  }
3988
+ return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
3674
3989
  }
3675
- }
3676
3990
 
3677
- // sync text nodes
3678
- if (type === 8 /* comment */ || type === 3 /* text */) {
3679
- if (to.nodeValue !== from.nodeValue) {
3680
- to.nodeValue = from.nodeValue;
3681
- }
3682
- }
3991
+ /**
3992
+ * syncs a given node with another node, copying over all attributes and
3993
+ * inner element state from the 'from' node to the 'to' node
3994
+ *
3995
+ * @param {Element} from the element to copy attributes & state from
3996
+ * @param {Element} to the element to copy attributes & state to
3997
+ * @param ctx the merge context
3998
+ */
3999
+ function syncNodeFrom(from, to, ctx) {
4000
+ let type = from.nodeType;
4001
+
4002
+ // if is an element type, sync the attributes from the
4003
+ // new node into the new node
4004
+ if (type === 1 /* element type */) {
4005
+ const fromAttributes = from.attributes;
4006
+ const toAttributes = to.attributes;
4007
+ for (const fromAttribute of fromAttributes) {
4008
+ if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
4009
+ continue;
4010
+ }
4011
+ if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
4012
+ to.setAttribute(fromAttribute.name, fromAttribute.value);
4013
+ }
4014
+ }
4015
+ // iterate backwards to avoid skipping over items when a delete occurs
4016
+ for (let i = toAttributes.length - 1; 0 <= i; i--) {
4017
+ const toAttribute = toAttributes[i];
4018
+ if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
4019
+ continue;
4020
+ }
4021
+ if (!from.hasAttribute(toAttribute.name)) {
4022
+ to.removeAttribute(toAttribute.name);
4023
+ }
4024
+ }
4025
+ }
3683
4026
 
3684
- // NB: many bothans died to bring us information:
3685
- //
3686
- // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
3687
- // https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
3688
-
3689
- // sync input value
3690
- if (from instanceof HTMLInputElement &&
3691
- to instanceof HTMLInputElement &&
3692
- from.type !== 'file') {
3693
-
3694
- to.value = from.value || '';
3695
- syncAttribute(from, to, 'value');
3696
-
3697
- // sync boolean attributes
3698
- syncAttribute(from, to, 'checked');
3699
- syncAttribute(from, to, 'disabled');
3700
- } else if (from instanceof HTMLOptionElement) {
3701
- syncAttribute(from, to, 'selected');
3702
- } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
3703
- let fromValue = from.value;
3704
- let toValue = to.value;
3705
- if (fromValue !== toValue) {
3706
- to.value = fromValue;
3707
- }
3708
- if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
3709
- to.firstChild.nodeValue = fromValue;
3710
- }
3711
- }
3712
- }
4027
+ // sync text nodes
4028
+ if (type === 8 /* comment */ || type === 3 /* text */) {
4029
+ if (to.nodeValue !== from.nodeValue) {
4030
+ to.nodeValue = from.nodeValue;
4031
+ }
4032
+ }
3713
4033
 
3714
- function syncAttribute(from, to, attributeName) {
3715
- if (from[attributeName] !== to[attributeName]) {
3716
- if (from[attributeName]) {
3717
- to.setAttribute(attributeName, from[attributeName]);
3718
- } else {
3719
- to.removeAttribute(attributeName);
4034
+ if (!ignoreValueOfActiveElement(to, ctx)) {
4035
+ // sync input values
4036
+ syncInputValue(from, to, ctx);
4037
+ }
3720
4038
  }
3721
- }
3722
- }
3723
4039
 
3724
- //=============================================================================
3725
- // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
3726
- //=============================================================================
3727
- function handleHeadElement(newHeadTag, currentHead, ctx) {
4040
+ /**
4041
+ * @param from {Element} element to sync the value from
4042
+ * @param to {Element} element to sync the value to
4043
+ * @param attributeName {String} the attribute name
4044
+ * @param ctx the merge context
4045
+ */
4046
+ function syncBooleanAttribute(from, to, attributeName, ctx) {
4047
+ if (from[attributeName] !== to[attributeName]) {
4048
+ let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
4049
+ if (!ignoreUpdate) {
4050
+ to[attributeName] = from[attributeName];
4051
+ }
4052
+ if (from[attributeName]) {
4053
+ if (!ignoreUpdate) {
4054
+ to.setAttribute(attributeName, from[attributeName]);
4055
+ }
4056
+ } else {
4057
+ if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
4058
+ to.removeAttribute(attributeName);
4059
+ }
4060
+ }
4061
+ }
4062
+ }
3728
4063
 
3729
- let added = [];
3730
- let removed = [];
3731
- let preserved = [];
3732
- let nodesToAppend = [];
4064
+ /**
4065
+ * NB: many bothans died to bring us information:
4066
+ *
4067
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
4068
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
4069
+ *
4070
+ * @param from {Element} the element to sync the input value from
4071
+ * @param to {Element} the element to sync the input value to
4072
+ * @param ctx the merge context
4073
+ */
4074
+ function syncInputValue(from, to, ctx) {
4075
+ if (from instanceof HTMLInputElement &&
4076
+ to instanceof HTMLInputElement &&
4077
+ from.type !== 'file') {
4078
+
4079
+ let fromValue = from.value;
4080
+ let toValue = to.value;
4081
+
4082
+ // sync boolean attributes
4083
+ syncBooleanAttribute(from, to, 'checked', ctx);
4084
+ syncBooleanAttribute(from, to, 'disabled', ctx);
4085
+
4086
+ if (!from.hasAttribute('value')) {
4087
+ if (!ignoreAttribute('value', to, 'remove', ctx)) {
4088
+ to.value = '';
4089
+ to.removeAttribute('value');
4090
+ }
4091
+ } else if (fromValue !== toValue) {
4092
+ if (!ignoreAttribute('value', to, 'update', ctx)) {
4093
+ to.setAttribute('value', fromValue);
4094
+ to.value = fromValue;
4095
+ }
4096
+ }
4097
+ } else if (from instanceof HTMLOptionElement) {
4098
+ syncBooleanAttribute(from, to, 'selected', ctx);
4099
+ } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
4100
+ let fromValue = from.value;
4101
+ let toValue = to.value;
4102
+ if (ignoreAttribute('value', to, 'update', ctx)) {
4103
+ return;
4104
+ }
4105
+ if (fromValue !== toValue) {
4106
+ to.value = fromValue;
4107
+ }
4108
+ if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
4109
+ to.firstChild.nodeValue = fromValue;
4110
+ }
4111
+ }
4112
+ }
3733
4113
 
3734
- let headMergeStyle = ctx.head.style;
4114
+ //=============================================================================
4115
+ // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
4116
+ //=============================================================================
4117
+ function handleHeadElement(newHeadTag, currentHead, ctx) {
3735
4118
 
3736
- // put all new head elements into a Map, by their outerHTML
3737
- let srcToNewHeadNodes = new Map();
3738
- for (const newHeadChild of newHeadTag.children) {
3739
- srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3740
- }
4119
+ let added = [];
4120
+ let removed = [];
4121
+ let preserved = [];
4122
+ let nodesToAppend = [];
3741
4123
 
3742
- // for each elt in the current head
3743
- for (const currentHeadElt of currentHead.children) {
4124
+ let headMergeStyle = ctx.head.style;
3744
4125
 
3745
- // If the current head element is in the map
3746
- let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
3747
- let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
3748
- let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
3749
- if (inNewContent || isPreserved) {
3750
- if (isReAppended) {
3751
- // remove the current version and let the new version replace it and re-execute
3752
- removed.push(currentHeadElt);
3753
- } else {
3754
- // this element already exists and should not be re-appended, so remove it from
3755
- // the new content map, preserving it in the DOM
3756
- srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
3757
- preserved.push(currentHeadElt);
4126
+ // put all new head elements into a Map, by their outerHTML
4127
+ let srcToNewHeadNodes = new Map();
4128
+ for (const newHeadChild of newHeadTag.children) {
4129
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3758
4130
  }
3759
- } else {
3760
- if (headMergeStyle === "append") {
3761
- // we are appending and this existing element is not new content
3762
- // so if and only if it is marked for re-append do we do anything
3763
- if (isReAppended) {
3764
- removed.push(currentHeadElt);
3765
- nodesToAppend.push(currentHeadElt);
4131
+
4132
+ // for each elt in the current head
4133
+ for (const currentHeadElt of currentHead.children) {
4134
+
4135
+ // If the current head element is in the map
4136
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
4137
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
4138
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
4139
+ if (inNewContent || isPreserved) {
4140
+ if (isReAppended) {
4141
+ // remove the current version and let the new version replace it and re-execute
4142
+ removed.push(currentHeadElt);
4143
+ } else {
4144
+ // this element already exists and should not be re-appended, so remove it from
4145
+ // the new content map, preserving it in the DOM
4146
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
4147
+ preserved.push(currentHeadElt);
4148
+ }
4149
+ } else {
4150
+ if (headMergeStyle === "append") {
4151
+ // we are appending and this existing element is not new content
4152
+ // so if and only if it is marked for re-append do we do anything
4153
+ if (isReAppended) {
4154
+ removed.push(currentHeadElt);
4155
+ nodesToAppend.push(currentHeadElt);
4156
+ }
4157
+ } else {
4158
+ // if this is a merge, we remove this content since it is not in the new head
4159
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
4160
+ removed.push(currentHeadElt);
4161
+ }
4162
+ }
3766
4163
  }
3767
- } else {
3768
- // if this is a merge, we remove this content since it is not in the new head
3769
- if (ctx.head.shouldRemove(currentHeadElt) !== false) {
3770
- removed.push(currentHeadElt);
4164
+ }
4165
+
4166
+ // Push the remaining new head elements in the Map into the
4167
+ // nodes to append to the head tag
4168
+ nodesToAppend.push(...srcToNewHeadNodes.values());
4169
+
4170
+ let promises = [];
4171
+ for (const newNode of nodesToAppend) {
4172
+ let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
4173
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
4174
+ if (newElt.href || newElt.src) {
4175
+ let resolve = null;
4176
+ let promise = new Promise(function (_resolve) {
4177
+ resolve = _resolve;
4178
+ });
4179
+ newElt.addEventListener('load', function () {
4180
+ resolve();
4181
+ });
4182
+ promises.push(promise);
4183
+ }
4184
+ currentHead.appendChild(newElt);
4185
+ ctx.callbacks.afterNodeAdded(newElt);
4186
+ added.push(newElt);
3771
4187
  }
3772
4188
  }
3773
- }
3774
- }
3775
4189
 
3776
- // Push the remaining new head elements in the Map into the
3777
- // nodes to append to the head tag
3778
- nodesToAppend.push(...srcToNewHeadNodes.values());
3779
-
3780
- let promises = [];
3781
- for (const newNode of nodesToAppend) {
3782
- let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
3783
- if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
3784
- if (newElt.href || newElt.src) {
3785
- let resolve = null;
3786
- let promise = new Promise(function (_resolve) {
3787
- resolve = _resolve;
3788
- });
3789
- newElt.addEventListener('load',function() {
3790
- resolve();
3791
- });
3792
- promises.push(promise);
4190
+ // remove all removed elements, after we have appended the new elements to avoid
4191
+ // additional network requests for things like style sheets
4192
+ for (const removedElement of removed) {
4193
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
4194
+ currentHead.removeChild(removedElement);
4195
+ ctx.callbacks.afterNodeRemoved(removedElement);
4196
+ }
3793
4197
  }
3794
- currentHead.appendChild(newElt);
3795
- ctx.callbacks.afterNodeAdded(newElt);
3796
- added.push(newElt);
3797
- }
3798
- }
3799
4198
 
3800
- // remove all removed elements, after we have appended the new elements to avoid
3801
- // additional network requests for things like style sheets
3802
- for (const removedElement of removed) {
3803
- if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
3804
- currentHead.removeChild(removedElement);
3805
- ctx.callbacks.afterNodeRemoved(removedElement);
4199
+ ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
4200
+ return promises;
3806
4201
  }
3807
- }
3808
4202
 
3809
- ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
3810
- return promises;
3811
- }
4203
+ function noOp() {
4204
+ }
3812
4205
 
3813
- function noOp() {}
4206
+ /*
4207
+ Deep merges the config object and the Idiomoroph.defaults object to
4208
+ produce a final configuration object
4209
+ */
4210
+ function mergeDefaults(config) {
4211
+ let finalConfig = {};
4212
+ // copy top level stuff into final config
4213
+ Object.assign(finalConfig, defaults);
4214
+ Object.assign(finalConfig, config);
4215
+
4216
+ // copy callbacks into final config (do this to deep merge the callbacks)
4217
+ finalConfig.callbacks = {};
4218
+ Object.assign(finalConfig.callbacks, defaults.callbacks);
4219
+ Object.assign(finalConfig.callbacks, config.callbacks);
4220
+
4221
+ // copy head config into final config (do this to deep merge the head)
4222
+ finalConfig.head = {};
4223
+ Object.assign(finalConfig.head, defaults.head);
4224
+ Object.assign(finalConfig.head, config.head);
4225
+ return finalConfig;
4226
+ }
3814
4227
 
3815
- function createMorphContext(oldNode, newContent, config) {
3816
- return {
3817
- target:oldNode,
3818
- newContent: newContent,
3819
- config: config,
3820
- morphStyle : config.morphStyle,
3821
- ignoreActive : config.ignoreActive,
3822
- idMap: createIdMap(oldNode, newContent),
3823
- deadIds: new Set(),
3824
- callbacks: Object.assign({
3825
- beforeNodeAdded: noOp,
3826
- afterNodeAdded : noOp,
3827
- beforeNodeMorphed: noOp,
3828
- afterNodeMorphed : noOp,
3829
- beforeNodeRemoved: noOp,
3830
- afterNodeRemoved : noOp,
3831
-
3832
- }, config.callbacks),
3833
- head: Object.assign({
3834
- style: 'merge',
3835
- shouldPreserve : function(elt) {
3836
- return elt.getAttribute("im-preserve") === "true";
3837
- },
3838
- shouldReAppend : function(elt) {
3839
- return elt.getAttribute("im-re-append") === "true";
3840
- },
3841
- shouldRemove : noOp,
3842
- afterHeadMorphed : noOp,
3843
- }, config.head),
3844
- }
3845
- }
4228
+ function createMorphContext(oldNode, newContent, config) {
4229
+ config = mergeDefaults(config);
4230
+ return {
4231
+ target: oldNode,
4232
+ newContent: newContent,
4233
+ config: config,
4234
+ morphStyle: config.morphStyle,
4235
+ ignoreActive: config.ignoreActive,
4236
+ ignoreActiveValue: config.ignoreActiveValue,
4237
+ idMap: createIdMap(oldNode, newContent),
4238
+ deadIds: new Set(),
4239
+ callbacks: config.callbacks,
4240
+ head: config.head
4241
+ }
4242
+ }
3846
4243
 
3847
- function isIdSetMatch(node1, node2, ctx) {
3848
- if (node1 == null || node2 == null) {
3849
- return false;
3850
- }
3851
- if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
3852
- if (node1.id !== "" && node1.id === node2.id) {
3853
- return true;
3854
- } else {
3855
- return getIdIntersectionCount(ctx, node1, node2) > 0;
4244
+ function isIdSetMatch(node1, node2, ctx) {
4245
+ if (node1 == null || node2 == null) {
4246
+ return false;
4247
+ }
4248
+ if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
4249
+ if (node1.id !== "" && node1.id === node2.id) {
4250
+ return true;
4251
+ } else {
4252
+ return getIdIntersectionCount(ctx, node1, node2) > 0;
4253
+ }
4254
+ }
4255
+ return false;
3856
4256
  }
3857
- }
3858
- return false;
3859
- }
3860
4257
 
3861
- function isSoftMatch(node1, node2) {
3862
- if (node1 == null || node2 == null) {
3863
- return false;
3864
- }
3865
- return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
3866
- }
4258
+ function isSoftMatch(node1, node2) {
4259
+ if (node1 == null || node2 == null) {
4260
+ return false;
4261
+ }
4262
+ return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
4263
+ }
3867
4264
 
3868
- function removeNodesBetween(startInclusive, endExclusive, ctx) {
3869
- while (startInclusive !== endExclusive) {
3870
- let tempNode = startInclusive;
3871
- startInclusive = startInclusive.nextSibling;
3872
- removeNode(tempNode, ctx);
3873
- }
3874
- removeIdsFromConsideration(ctx, endExclusive);
3875
- return endExclusive.nextSibling;
3876
- }
4265
+ function removeNodesBetween(startInclusive, endExclusive, ctx) {
4266
+ while (startInclusive !== endExclusive) {
4267
+ let tempNode = startInclusive;
4268
+ startInclusive = startInclusive.nextSibling;
4269
+ removeNode(tempNode, ctx);
4270
+ }
4271
+ removeIdsFromConsideration(ctx, endExclusive);
4272
+ return endExclusive.nextSibling;
4273
+ }
3877
4274
 
3878
- //=============================================================================
3879
- // Scans forward from the insertionPoint in the old parent looking for a potential id match
3880
- // for the newChild. We stop if we find a potential id match for the new child OR
3881
- // if the number of potential id matches we are discarding is greater than the
3882
- // potential id matches for the new child
3883
- //=============================================================================
3884
- function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4275
+ //=============================================================================
4276
+ // Scans forward from the insertionPoint in the old parent looking for a potential id match
4277
+ // for the newChild. We stop if we find a potential id match for the new child OR
4278
+ // if the number of potential id matches we are discarding is greater than the
4279
+ // potential id matches for the new child
4280
+ //=============================================================================
4281
+ function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4282
+
4283
+ // max id matches we are willing to discard in our search
4284
+ let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4285
+
4286
+ let potentialMatch = null;
4287
+
4288
+ // only search forward if there is a possibility of an id match
4289
+ if (newChildPotentialIdCount > 0) {
4290
+ let potentialMatch = insertionPoint;
4291
+ // if there is a possibility of an id match, scan forward
4292
+ // keep track of the potential id match count we are discarding (the
4293
+ // newChildPotentialIdCount must be greater than this to make it likely
4294
+ // worth it)
4295
+ let otherMatchCount = 0;
4296
+ while (potentialMatch != null) {
4297
+
4298
+ // If we have an id match, return the current potential match
4299
+ if (isIdSetMatch(newChild, potentialMatch, ctx)) {
4300
+ return potentialMatch;
4301
+ }
3885
4302
 
3886
- // max id matches we are willing to discard in our search
3887
- let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4303
+ // computer the other potential matches of this new content
4304
+ otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
4305
+ if (otherMatchCount > newChildPotentialIdCount) {
4306
+ // if we have more potential id matches in _other_ content, we
4307
+ // do not have a good candidate for an id match, so return null
4308
+ return null;
4309
+ }
3888
4310
 
3889
- let potentialMatch = null;
4311
+ // advanced to the next old content child
4312
+ potentialMatch = potentialMatch.nextSibling;
4313
+ }
4314
+ }
4315
+ return potentialMatch;
4316
+ }
3890
4317
 
3891
- // only search forward if there is a possibility of an id match
3892
- if (newChildPotentialIdCount > 0) {
3893
- let potentialMatch = insertionPoint;
3894
- // if there is a possibility of an id match, scan forward
3895
- // keep track of the potential id match count we are discarding (the
3896
- // newChildPotentialIdCount must be greater than this to make it likely
3897
- // worth it)
3898
- let otherMatchCount = 0;
3899
- while (potentialMatch != null) {
4318
+ //=============================================================================
4319
+ // Scans forward from the insertionPoint in the old parent looking for a potential soft match
4320
+ // for the newChild. We stop if we find a potential soft match for the new child OR
4321
+ // if we find a potential id match in the old parents children OR if we find two
4322
+ // potential soft matches for the next two pieces of new content
4323
+ //=============================================================================
4324
+ function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
3900
4325
 
3901
- // If we have an id match, return the current potential match
3902
- if (isIdSetMatch(newChild, potentialMatch, ctx)) {
3903
- return potentialMatch;
3904
- }
4326
+ let potentialSoftMatch = insertionPoint;
4327
+ let nextSibling = newChild.nextSibling;
4328
+ let siblingSoftMatchCount = 0;
3905
4329
 
3906
- // computer the other potential matches of this new content
3907
- otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
3908
- if (otherMatchCount > newChildPotentialIdCount) {
3909
- // if we have more potential id matches in _other_ content, we
3910
- // do not have a good candidate for an id match, so return null
3911
- return null;
3912
- }
4330
+ while (potentialSoftMatch != null) {
3913
4331
 
3914
- // advanced to the next old content child
3915
- potentialMatch = potentialMatch.nextSibling;
3916
- }
3917
- }
3918
- return potentialMatch;
3919
- }
4332
+ if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
4333
+ // the current potential soft match has a potential id set match with the remaining new
4334
+ // content so bail out of looking
4335
+ return null;
4336
+ }
3920
4337
 
3921
- //=============================================================================
3922
- // Scans forward from the insertionPoint in the old parent looking for a potential soft match
3923
- // for the newChild. We stop if we find a potential soft match for the new child OR
3924
- // if we find a potential id match in the old parents children OR if we find two
3925
- // potential soft matches for the next two pieces of new content
3926
- //=============================================================================
3927
- function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4338
+ // if we have a soft match with the current node, return it
4339
+ if (isSoftMatch(newChild, potentialSoftMatch)) {
4340
+ return potentialSoftMatch;
4341
+ }
3928
4342
 
3929
- let potentialSoftMatch = insertionPoint;
3930
- let nextSibling = newChild.nextSibling;
3931
- let siblingSoftMatchCount = 0;
4343
+ if (isSoftMatch(nextSibling, potentialSoftMatch)) {
4344
+ // the next new node has a soft match with this node, so
4345
+ // increment the count of future soft matches
4346
+ siblingSoftMatchCount++;
4347
+ nextSibling = nextSibling.nextSibling;
3932
4348
 
3933
- while (potentialSoftMatch != null) {
4349
+ // If there are two future soft matches, bail to allow the siblings to soft match
4350
+ // so that we don't consume future soft matches for the sake of the current node
4351
+ if (siblingSoftMatchCount >= 2) {
4352
+ return null;
4353
+ }
4354
+ }
3934
4355
 
3935
- if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
3936
- // the current potential soft match has a potential id set match with the remaining new
3937
- // content so bail out of looking
3938
- return null;
3939
- }
4356
+ // advanced to the next old content child
4357
+ potentialSoftMatch = potentialSoftMatch.nextSibling;
4358
+ }
3940
4359
 
3941
- // if we have a soft match with the current node, return it
3942
- if (isSoftMatch(newChild, potentialSoftMatch)) {
3943
4360
  return potentialSoftMatch;
3944
4361
  }
3945
4362
 
3946
- if (isSoftMatch(nextSibling, potentialSoftMatch)) {
3947
- // the next new node has a soft match with this node, so
3948
- // increment the count of future soft matches
3949
- siblingSoftMatchCount++;
3950
- nextSibling = nextSibling.nextSibling;
3951
-
3952
- // If there are two future soft matches, bail to allow the siblings to soft match
3953
- // so that we don't consume future soft matches for the sake of the current node
3954
- if (siblingSoftMatchCount >= 2) {
3955
- return null;
4363
+ function parseContent(newContent) {
4364
+ let parser = new DOMParser();
4365
+
4366
+ // remove svgs to avoid false-positive matches on head, etc.
4367
+ let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
4368
+
4369
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
4370
+ if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
4371
+ let content = parser.parseFromString(newContent, "text/html");
4372
+ // if it is a full HTML document, return the document itself as the parent container
4373
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
4374
+ content.generatedByIdiomorph = true;
4375
+ return content;
4376
+ } else {
4377
+ // otherwise return the html element as the parent container
4378
+ let htmlElement = content.firstChild;
4379
+ if (htmlElement) {
4380
+ htmlElement.generatedByIdiomorph = true;
4381
+ return htmlElement;
4382
+ } else {
4383
+ return null;
4384
+ }
4385
+ }
4386
+ } else {
4387
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
4388
+ // deal with touchy tags like tr, tbody, etc.
4389
+ let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
4390
+ let content = responseDoc.body.querySelector('template').content;
4391
+ content.generatedByIdiomorph = true;
4392
+ return content
3956
4393
  }
3957
4394
  }
3958
4395
 
3959
- // advanced to the next old content child
3960
- potentialSoftMatch = potentialSoftMatch.nextSibling;
3961
- }
3962
-
3963
- return potentialSoftMatch;
3964
- }
3965
-
3966
- function parseContent(newContent) {
3967
- let parser = new DOMParser();
3968
-
3969
- // remove svgs to avoid false-positive matches on head, etc.
3970
- let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
3971
-
3972
- // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
3973
- if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
3974
- let content = parser.parseFromString(newContent, "text/html");
3975
- // if it is a full HTML document, return the document itself as the parent container
3976
- if (contentWithSvgsRemoved.match(/<\/html>/)) {
3977
- content.generatedByIdiomorph = true;
3978
- return content;
3979
- } else {
3980
- // otherwise return the html element as the parent container
3981
- let htmlElement = content.firstChild;
3982
- if (htmlElement) {
3983
- htmlElement.generatedByIdiomorph = true;
3984
- return htmlElement;
4396
+ function normalizeContent(newContent) {
4397
+ if (newContent == null) {
4398
+ // noinspection UnnecessaryLocalVariableJS
4399
+ const dummyParent = document.createElement('div');
4400
+ return dummyParent;
4401
+ } else if (newContent.generatedByIdiomorph) {
4402
+ // the template tag created by idiomorph parsing can serve as a dummy parent
4403
+ return newContent;
4404
+ } else if (newContent instanceof Node) {
4405
+ // a single node is added as a child to a dummy parent
4406
+ const dummyParent = document.createElement('div');
4407
+ dummyParent.append(newContent);
4408
+ return dummyParent;
3985
4409
  } else {
3986
- return null;
4410
+ // all nodes in the array or HTMLElement collection are consolidated under
4411
+ // a single dummy parent element
4412
+ const dummyParent = document.createElement('div');
4413
+ for (const elt of [...newContent]) {
4414
+ dummyParent.append(elt);
4415
+ }
4416
+ return dummyParent;
3987
4417
  }
3988
4418
  }
3989
- } else {
3990
- // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
3991
- // deal with touchy tags like tr, tbody, etc.
3992
- let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
3993
- let content = responseDoc.body.querySelector('template').content;
3994
- content.generatedByIdiomorph = true;
3995
- return content
3996
- }
3997
- }
3998
-
3999
- function normalizeContent(newContent) {
4000
- if (newContent == null) {
4001
- // noinspection UnnecessaryLocalVariableJS
4002
- const dummyParent = document.createElement('div');
4003
- return dummyParent;
4004
- } else if (newContent.generatedByIdiomorph) {
4005
- // the template tag created by idiomorph parsing can serve as a dummy parent
4006
- return newContent;
4007
- } else if (newContent instanceof Node) {
4008
- // a single node is added as a child to a dummy parent
4009
- const dummyParent = document.createElement('div');
4010
- dummyParent.append(newContent);
4011
- return dummyParent;
4012
- } else {
4013
- // all nodes in the array or HTMLElement collection are consolidated under
4014
- // a single dummy parent element
4015
- const dummyParent = document.createElement('div');
4016
- for (const elt of [...newContent]) {
4017
- dummyParent.append(elt);
4018
- }
4019
- return dummyParent;
4020
- }
4021
- }
4022
4419
 
4023
- function insertSiblings(previousSibling, morphedNode, nextSibling) {
4024
- let stack = [];
4025
- let added = [];
4026
- while (previousSibling != null) {
4027
- stack.push(previousSibling);
4028
- previousSibling = previousSibling.previousSibling;
4029
- }
4030
- while (stack.length > 0) {
4031
- let node = stack.pop();
4032
- added.push(node); // push added preceding siblings on in order and insert
4033
- morphedNode.parentElement.insertBefore(node, morphedNode);
4034
- }
4035
- added.push(morphedNode);
4036
- while (nextSibling != null) {
4037
- stack.push(nextSibling);
4038
- added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4039
- nextSibling = nextSibling.nextSibling;
4040
- }
4041
- while (stack.length > 0) {
4042
- morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4043
- }
4044
- return added;
4045
- }
4420
+ function insertSiblings(previousSibling, morphedNode, nextSibling) {
4421
+ let stack = [];
4422
+ let added = [];
4423
+ while (previousSibling != null) {
4424
+ stack.push(previousSibling);
4425
+ previousSibling = previousSibling.previousSibling;
4426
+ }
4427
+ while (stack.length > 0) {
4428
+ let node = stack.pop();
4429
+ added.push(node); // push added preceding siblings on in order and insert
4430
+ morphedNode.parentElement.insertBefore(node, morphedNode);
4431
+ }
4432
+ added.push(morphedNode);
4433
+ while (nextSibling != null) {
4434
+ stack.push(nextSibling);
4435
+ added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4436
+ nextSibling = nextSibling.nextSibling;
4437
+ }
4438
+ while (stack.length > 0) {
4439
+ morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4440
+ }
4441
+ return added;
4442
+ }
4046
4443
 
4047
- function findBestNodeMatch(newContent, oldNode, ctx) {
4048
- let currentElement;
4049
- currentElement = newContent.firstChild;
4050
- let bestElement = currentElement;
4051
- let score = 0;
4052
- while (currentElement) {
4053
- let newScore = scoreElement(currentElement, oldNode, ctx);
4054
- if (newScore > score) {
4055
- bestElement = currentElement;
4056
- score = newScore;
4444
+ function findBestNodeMatch(newContent, oldNode, ctx) {
4445
+ let currentElement;
4446
+ currentElement = newContent.firstChild;
4447
+ let bestElement = currentElement;
4448
+ let score = 0;
4449
+ while (currentElement) {
4450
+ let newScore = scoreElement(currentElement, oldNode, ctx);
4451
+ if (newScore > score) {
4452
+ bestElement = currentElement;
4453
+ score = newScore;
4454
+ }
4455
+ currentElement = currentElement.nextSibling;
4456
+ }
4457
+ return bestElement;
4057
4458
  }
4058
- currentElement = currentElement.nextSibling;
4059
- }
4060
- return bestElement;
4061
- }
4062
4459
 
4063
- function scoreElement(node1, node2, ctx) {
4064
- if (isSoftMatch(node1, node2)) {
4065
- return .5 + getIdIntersectionCount(ctx, node1, node2);
4066
- }
4067
- return 0;
4068
- }
4460
+ function scoreElement(node1, node2, ctx) {
4461
+ if (isSoftMatch(node1, node2)) {
4462
+ return .5 + getIdIntersectionCount(ctx, node1, node2);
4463
+ }
4464
+ return 0;
4465
+ }
4069
4466
 
4070
- function removeNode(tempNode, ctx) {
4071
- removeIdsFromConsideration(ctx, tempNode);
4072
- if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4467
+ function removeNode(tempNode, ctx) {
4468
+ removeIdsFromConsideration(ctx, tempNode);
4469
+ if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4073
4470
 
4074
- tempNode.remove();
4075
- ctx.callbacks.afterNodeRemoved(tempNode);
4076
- }
4471
+ tempNode.remove();
4472
+ ctx.callbacks.afterNodeRemoved(tempNode);
4473
+ }
4077
4474
 
4078
- //=============================================================================
4079
- // ID Set Functions
4080
- //=============================================================================
4475
+ //=============================================================================
4476
+ // ID Set Functions
4477
+ //=============================================================================
4081
4478
 
4082
- function isIdInConsideration(ctx, id) {
4083
- return !ctx.deadIds.has(id);
4084
- }
4479
+ function isIdInConsideration(ctx, id) {
4480
+ return !ctx.deadIds.has(id);
4481
+ }
4085
4482
 
4086
- function idIsWithinNode(ctx, id, targetNode) {
4087
- let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4088
- return idSet.has(id);
4089
- }
4483
+ function idIsWithinNode(ctx, id, targetNode) {
4484
+ let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4485
+ return idSet.has(id);
4486
+ }
4090
4487
 
4091
- function removeIdsFromConsideration(ctx, node) {
4092
- let idSet = ctx.idMap.get(node) || EMPTY_SET;
4093
- for (const id of idSet) {
4094
- ctx.deadIds.add(id);
4095
- }
4096
- }
4488
+ function removeIdsFromConsideration(ctx, node) {
4489
+ let idSet = ctx.idMap.get(node) || EMPTY_SET;
4490
+ for (const id of idSet) {
4491
+ ctx.deadIds.add(id);
4492
+ }
4493
+ }
4097
4494
 
4098
- function getIdIntersectionCount(ctx, node1, node2) {
4099
- let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4100
- let matchCount = 0;
4101
- for (const id of sourceSet) {
4102
- // a potential match is an id in the source and potentialIdsSet, but
4103
- // that has not already been merged into the DOM
4104
- if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4105
- ++matchCount;
4495
+ function getIdIntersectionCount(ctx, node1, node2) {
4496
+ let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4497
+ let matchCount = 0;
4498
+ for (const id of sourceSet) {
4499
+ // a potential match is an id in the source and potentialIdsSet, but
4500
+ // that has not already been merged into the DOM
4501
+ if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4502
+ ++matchCount;
4503
+ }
4504
+ }
4505
+ return matchCount;
4106
4506
  }
4107
- }
4108
- return matchCount;
4109
- }
4110
4507
 
4111
- /**
4112
- * A bottom up algorithm that finds all elements with ids inside of the node
4113
- * argument and populates id sets for those nodes and all their parents, generating
4114
- * a set of ids contained within all nodes for the entire hierarchy in the DOM
4115
- *
4116
- * @param node {Element}
4117
- * @param {Map<Node, Set<String>>} idMap
4118
- */
4119
- function populateIdMapForNode(node, idMap) {
4120
- let nodeParent = node.parentElement;
4121
- // find all elements with an id property
4122
- let idElements = node.querySelectorAll('[id]');
4123
- for (const elt of idElements) {
4124
- let current = elt;
4125
- // walk up the parent hierarchy of that element, adding the id
4126
- // of element to the parent's id set
4127
- while (current !== nodeParent && current != null) {
4128
- let idSet = idMap.get(current);
4129
- // if the id set doesn't exist, create it and insert it in the map
4130
- if (idSet == null) {
4131
- idSet = new Set();
4132
- idMap.set(current, idSet);
4508
+ /**
4509
+ * A bottom up algorithm that finds all elements with ids inside of the node
4510
+ * argument and populates id sets for those nodes and all their parents, generating
4511
+ * a set of ids contained within all nodes for the entire hierarchy in the DOM
4512
+ *
4513
+ * @param node {Element}
4514
+ * @param {Map<Node, Set<String>>} idMap
4515
+ */
4516
+ function populateIdMapForNode(node, idMap) {
4517
+ let nodeParent = node.parentElement;
4518
+ // find all elements with an id property
4519
+ let idElements = node.querySelectorAll('[id]');
4520
+ for (const elt of idElements) {
4521
+ let current = elt;
4522
+ // walk up the parent hierarchy of that element, adding the id
4523
+ // of element to the parent's id set
4524
+ while (current !== nodeParent && current != null) {
4525
+ let idSet = idMap.get(current);
4526
+ // if the id set doesn't exist, create it and insert it in the map
4527
+ if (idSet == null) {
4528
+ idSet = new Set();
4529
+ idMap.set(current, idSet);
4530
+ }
4531
+ idSet.add(elt.id);
4532
+ current = current.parentElement;
4533
+ }
4133
4534
  }
4134
- idSet.add(elt.id);
4135
- current = current.parentElement;
4136
4535
  }
4137
- }
4138
- }
4139
4536
 
4140
- /**
4141
- * This function computes a map of nodes to all ids contained within that node (inclusive of the
4142
- * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4143
- * for a looser definition of "matching" than tradition id matching, and allows child nodes
4144
- * to contribute to a parent nodes matching.
4145
- *
4146
- * @param {Element} oldContent the old content that will be morphed
4147
- * @param {Element} newContent the new content to morph to
4148
- * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4149
- */
4150
- function createIdMap(oldContent, newContent) {
4151
- let idMap = new Map();
4152
- populateIdMapForNode(oldContent, idMap);
4153
- populateIdMapForNode(newContent, idMap);
4154
- return idMap;
4155
- }
4537
+ /**
4538
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
4539
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4540
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
4541
+ * to contribute to a parent nodes matching.
4542
+ *
4543
+ * @param {Element} oldContent the old content that will be morphed
4544
+ * @param {Element} newContent the new content to morph to
4545
+ * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4546
+ */
4547
+ function createIdMap(oldContent, newContent) {
4548
+ let idMap = new Map();
4549
+ populateIdMapForNode(oldContent, idMap);
4550
+ populateIdMapForNode(newContent, idMap);
4551
+ return idMap;
4552
+ }
4156
4553
 
4157
- //=============================================================================
4158
- // This is what ends up becoming the Idiomorph export
4159
- //=============================================================================
4160
- var idiomorph = { morph };
4554
+ //=============================================================================
4555
+ // This is what ends up becoming the Idiomorph global object
4556
+ //=============================================================================
4557
+ return {
4558
+ morph,
4559
+ defaults
4560
+ }
4561
+ })();
4161
4562
 
4162
4563
  class MorphRenderer extends Renderer {
4163
4564
  async render() {
@@ -4185,7 +4586,7 @@ Copyright © 2023 37signals LLC
4185
4586
  #morphElements(currentElement, newElement, morphStyle = "outerHTML") {
4186
4587
  this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement);
4187
4588
 
4188
- idiomorph.morph(currentElement, newElement, {
4589
+ Idiomorph.morph(currentElement, newElement, {
4189
4590
  morphStyle: morphStyle,
4190
4591
  callbacks: {
4191
4592
  beforeNodeAdded: this.#shouldAddElement,
@@ -4317,8 +4718,13 @@ Copyright © 2023 37signals LLC
4317
4718
  const mergedHeadElements = this.mergeProvisionalElements();
4318
4719
  const newStylesheetElements = this.copyNewHeadStylesheetElements();
4319
4720
  this.copyNewHeadScriptElements();
4721
+
4320
4722
  await mergedHeadElements;
4321
4723
  await newStylesheetElements;
4724
+
4725
+ if (this.willRender) {
4726
+ this.removeUnusedHeadStylesheetElements();
4727
+ }
4322
4728
  }
4323
4729
 
4324
4730
  async replaceBody() {
@@ -4350,6 +4756,12 @@ Copyright © 2023 37signals LLC
4350
4756
  }
4351
4757
  }
4352
4758
 
4759
+ removeUnusedHeadStylesheetElements() {
4760
+ for (const element of this.unusedHeadStylesheetElements) {
4761
+ document.head.removeChild(element);
4762
+ }
4763
+ }
4764
+
4353
4765
  async mergeProvisionalElements() {
4354
4766
  const newHeadElements = [...this.newHeadProvisionalElements];
4355
4767
 
@@ -4415,6 +4827,20 @@ Copyright © 2023 37signals LLC
4415
4827
  await this.renderElement(this.currentElement, this.newElement);
4416
4828
  }
4417
4829
 
4830
+ get unusedHeadStylesheetElements() {
4831
+ return this.oldHeadStylesheetElements.filter((element) => {
4832
+ return !(element.hasAttribute("data-turbo-permanent") ||
4833
+ // Trix dynamically adds styles to the head that we want to keep around which have a
4834
+ // `data-tag-name` attribute. Long term we should moves those styles to Trix's CSS file
4835
+ // but for now we'll just skip removing them
4836
+ element.hasAttribute("data-tag-name"))
4837
+ })
4838
+ }
4839
+
4840
+ get oldHeadStylesheetElements() {
4841
+ return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
4842
+ }
4843
+
4418
4844
  get newHeadStylesheetElements() {
4419
4845
  return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
4420
4846
  }
@@ -4541,7 +4967,11 @@ Copyright © 2023 37signals LLC
4541
4967
  }
4542
4968
 
4543
4969
  isPageRefresh(visit) {
4544
- return !visit || (this.lastRenderedLocation.href === visit.location.href && visit.action === "replace")
4970
+ return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace")
4971
+ }
4972
+
4973
+ shouldPreserveScrollPosition(visit) {
4974
+ return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition
4545
4975
  }
4546
4976
 
4547
4977
  get snapshot() {
@@ -4552,27 +4982,28 @@ Copyright © 2023 37signals LLC
4552
4982
  class Preloader {
4553
4983
  selector = "a[data-turbo-preload]"
4554
4984
 
4555
- constructor(delegate) {
4985
+ constructor(delegate, snapshotCache) {
4556
4986
  this.delegate = delegate;
4557
- }
4558
-
4559
- get snapshotCache() {
4560
- return this.delegate.navigator.view.snapshotCache
4987
+ this.snapshotCache = snapshotCache;
4561
4988
  }
4562
4989
 
4563
4990
  start() {
4564
4991
  if (document.readyState === "loading") {
4565
- return document.addEventListener("DOMContentLoaded", () => {
4566
- this.preloadOnLoadLinksForView(document.body);
4567
- })
4992
+ document.addEventListener("DOMContentLoaded", this.#preloadAll);
4568
4993
  } else {
4569
4994
  this.preloadOnLoadLinksForView(document.body);
4570
4995
  }
4571
4996
  }
4572
4997
 
4998
+ stop() {
4999
+ document.removeEventListener("DOMContentLoaded", this.#preloadAll);
5000
+ }
5001
+
4573
5002
  preloadOnLoadLinksForView(element) {
4574
5003
  for (const link of element.querySelectorAll(this.selector)) {
4575
- this.preloadURL(link);
5004
+ if (this.delegate.shouldPreloadLink(link)) {
5005
+ this.preloadURL(link);
5006
+ }
4576
5007
  }
4577
5008
  }
4578
5009
 
@@ -4583,31 +5014,39 @@ Copyright © 2023 37signals LLC
4583
5014
  return
4584
5015
  }
4585
5016
 
5017
+ const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link);
5018
+ await fetchRequest.perform();
5019
+ }
5020
+
5021
+ // Fetch request delegate
5022
+
5023
+ prepareRequest(fetchRequest) {
5024
+ fetchRequest.headers["Sec-Purpose"] = "prefetch";
5025
+ }
5026
+
5027
+ async requestSucceededWithResponse(fetchRequest, fetchResponse) {
4586
5028
  try {
4587
- const response = await fetch(location.toString(), { headers: { "Sec-Purpose": "prefetch", Accept: "text/html" } });
4588
- const responseText = await response.text();
4589
- const snapshot = PageSnapshot.fromHTMLString(responseText);
5029
+ const responseHTML = await fetchResponse.responseHTML;
5030
+ const snapshot = PageSnapshot.fromHTMLString(responseHTML);
4590
5031
 
4591
- this.snapshotCache.put(location, snapshot);
5032
+ this.snapshotCache.put(fetchRequest.url, snapshot);
4592
5033
  } catch (_) {
4593
5034
  // If we cannot preload that is ok!
4594
5035
  }
4595
5036
  }
4596
- }
4597
5037
 
4598
- class LimitedSet extends Set {
4599
- constructor(maxSize) {
4600
- super();
4601
- this.maxSize = maxSize;
4602
- }
5038
+ requestStarted(fetchRequest) {}
4603
5039
 
4604
- add(value) {
4605
- if (this.size >= this.maxSize) {
4606
- const iterator = this.values();
4607
- const oldestValue = iterator.next().value;
4608
- this.delete(oldestValue);
4609
- }
4610
- super.add(value);
5040
+ requestErrored(fetchRequest) {}
5041
+
5042
+ requestFinished(fetchRequest) {}
5043
+
5044
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
5045
+
5046
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
5047
+
5048
+ #preloadAll = () => {
5049
+ this.preloadOnLoadLinksForView(document.body);
4611
5050
  }
4612
5051
  }
4613
5052
 
@@ -4640,12 +5079,12 @@ Copyright © 2023 37signals LLC
4640
5079
  class Session {
4641
5080
  navigator = new Navigator(this)
4642
5081
  history = new History(this)
4643
- preloader = new Preloader(this)
4644
5082
  view = new PageView(this, document.documentElement)
4645
5083
  adapter = new BrowserAdapter(this)
4646
5084
 
4647
5085
  pageObserver = new PageObserver(this)
4648
5086
  cacheObserver = new CacheObserver()
5087
+ linkPrefetchObserver = new LinkPrefetchObserver(this, document)
4649
5088
  linkClickObserver = new LinkClickObserver(this, window)
4650
5089
  formSubmitObserver = new FormSubmitObserver(this, document)
4651
5090
  scrollObserver = new ScrollObserver(this)
@@ -4654,18 +5093,26 @@ Copyright © 2023 37signals LLC
4654
5093
  frameRedirector = new FrameRedirector(this, document.documentElement)
4655
5094
  streamMessageRenderer = new StreamMessageRenderer()
4656
5095
  cache = new Cache(this)
4657
- recentRequests = new LimitedSet(20)
4658
5096
 
4659
5097
  drive = true
4660
5098
  enabled = true
4661
5099
  progressBarDelay = 500
4662
5100
  started = false
4663
5101
  formMode = "on"
5102
+ #pageRefreshDebouncePeriod = 150
5103
+
5104
+ constructor(recentRequests) {
5105
+ this.recentRequests = recentRequests;
5106
+ this.preloader = new Preloader(this, this.view.snapshotCache);
5107
+ this.debouncedRefresh = this.refresh;
5108
+ this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
5109
+ }
4664
5110
 
4665
5111
  start() {
4666
5112
  if (!this.started) {
4667
5113
  this.pageObserver.start();
4668
5114
  this.cacheObserver.start();
5115
+ this.linkPrefetchObserver.start();
4669
5116
  this.formLinkClickObserver.start();
4670
5117
  this.linkClickObserver.start();
4671
5118
  this.formSubmitObserver.start();
@@ -4687,6 +5134,7 @@ Copyright © 2023 37signals LLC
4687
5134
  if (this.started) {
4688
5135
  this.pageObserver.stop();
4689
5136
  this.cacheObserver.stop();
5137
+ this.linkPrefetchObserver.stop();
4690
5138
  this.formLinkClickObserver.stop();
4691
5139
  this.linkClickObserver.stop();
4692
5140
  this.formSubmitObserver.stop();
@@ -4694,6 +5142,7 @@ Copyright © 2023 37signals LLC
4694
5142
  this.streamObserver.stop();
4695
5143
  this.frameRedirector.stop();
4696
5144
  this.history.stop();
5145
+ this.preloader.stop();
4697
5146
  this.started = false;
4698
5147
  }
4699
5148
  }
@@ -4753,13 +5202,42 @@ Copyright © 2023 37signals LLC
4753
5202
  return this.history.restorationIdentifier
4754
5203
  }
4755
5204
 
5205
+ get pageRefreshDebouncePeriod() {
5206
+ return this.#pageRefreshDebouncePeriod
5207
+ }
5208
+
5209
+ set pageRefreshDebouncePeriod(value) {
5210
+ this.refresh = debounce(this.debouncedRefresh.bind(this), value);
5211
+ this.#pageRefreshDebouncePeriod = value;
5212
+ }
5213
+
5214
+ // Preloader delegate
5215
+
5216
+ shouldPreloadLink(element) {
5217
+ const isUnsafe = element.hasAttribute("data-turbo-method");
5218
+ const isStream = element.hasAttribute("data-turbo-stream");
5219
+ const frameTarget = element.getAttribute("data-turbo-frame");
5220
+ const frame = frameTarget == "_top" ?
5221
+ null :
5222
+ document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])");
5223
+
5224
+ if (isUnsafe || isStream || frame instanceof FrameElement) {
5225
+ return false
5226
+ } else {
5227
+ const location = new URL(element.href);
5228
+
5229
+ return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation)
5230
+ }
5231
+ }
5232
+
4756
5233
  // History delegate
4757
5234
 
4758
- historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier) {
5235
+ historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) {
4759
5236
  if (this.enabled) {
4760
5237
  this.navigator.startVisit(location, restorationIdentifier, {
4761
5238
  action: "restore",
4762
- historyChanged: true
5239
+ historyChanged: true,
5240
+ direction
4763
5241
  });
4764
5242
  } else {
4765
5243
  this.adapter.pageInvalidated({
@@ -4782,6 +5260,15 @@ Copyright © 2023 37signals LLC
4782
5260
 
4783
5261
  submittedFormLinkToLocation() {}
4784
5262
 
5263
+ // Link hover observer delegate
5264
+
5265
+ canPrefetchRequestToLocation(link, location) {
5266
+ return (
5267
+ this.elementIsNavigatable(link) &&
5268
+ locationIsVisitable(location, this.snapshot.rootLocation)
5269
+ )
5270
+ }
5271
+
4785
5272
  // Link click observer delegate
4786
5273
 
4787
5274
  willFollowLinkToLocation(link, location, event) {
@@ -4815,6 +5302,7 @@ Copyright © 2023 37signals LLC
4815
5302
  visitStarted(visit) {
4816
5303
  if (!visit.acceptsStreamResponse) {
4817
5304
  markAsBusy(document.documentElement);
5305
+ this.view.markVisitDirection(visit.direction);
4818
5306
  }
4819
5307
  extendURLWithDeprecatedProperties(visit.location);
4820
5308
  if (!visit.silent) {
@@ -4823,6 +5311,7 @@ Copyright © 2023 37signals LLC
4823
5311
  }
4824
5312
 
4825
5313
  visitCompleted(visit) {
5314
+ this.view.unmarkVisitDirection();
4826
5315
  clearBusyState(document.documentElement);
4827
5316
  this.notifyApplicationAfterPageLoad(visit.getTimingMetrics());
4828
5317
  }
@@ -4879,8 +5368,8 @@ Copyright © 2023 37signals LLC
4879
5368
  }
4880
5369
  }
4881
5370
 
4882
- allowsImmediateRender({ element }, isPreview, options) {
4883
- const event = this.notifyApplicationBeforeRender(element, isPreview, options);
5371
+ allowsImmediateRender({ element }, options) {
5372
+ const event = this.notifyApplicationBeforeRender(element, options);
4884
5373
  const {
4885
5374
  defaultPrevented,
4886
5375
  detail: { render }
@@ -4893,9 +5382,9 @@ Copyright © 2023 37signals LLC
4893
5382
  return !defaultPrevented
4894
5383
  }
4895
5384
 
4896
- viewRenderedSnapshot(_snapshot, isPreview, renderMethod) {
5385
+ viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) {
4897
5386
  this.view.lastRenderedLocation = this.history.location;
4898
- this.notifyApplicationAfterRender(isPreview, renderMethod);
5387
+ this.notifyApplicationAfterRender(renderMethod);
4899
5388
  }
4900
5389
 
4901
5390
  preloadOnLoadLinksForView(element) {
@@ -4951,15 +5440,15 @@ Copyright © 2023 37signals LLC
4951
5440
  return dispatch("turbo:before-cache")
4952
5441
  }
4953
5442
 
4954
- notifyApplicationBeforeRender(newBody, isPreview, options) {
5443
+ notifyApplicationBeforeRender(newBody, options) {
4955
5444
  return dispatch("turbo:before-render", {
4956
- detail: { newBody, isPreview, ...options },
5445
+ detail: { newBody, ...options },
4957
5446
  cancelable: true
4958
5447
  })
4959
5448
  }
4960
5449
 
4961
- notifyApplicationAfterRender(isPreview, renderMethod) {
4962
- return dispatch("turbo:render", { detail: { isPreview, renderMethod } })
5450
+ notifyApplicationAfterRender(renderMethod) {
5451
+ return dispatch("turbo:render", { detail: { renderMethod } })
4963
5452
  }
4964
5453
 
4965
5454
  notifyApplicationAfterPageLoad(timing = {}) {
@@ -5061,7 +5550,7 @@ Copyright © 2023 37signals LLC
5061
5550
  }
5062
5551
  };
5063
5552
 
5064
- const session = new Session();
5553
+ const session = new Session(recentRequests);
5065
5554
  const { cache, navigator: navigator$1 } = session;
5066
5555
 
5067
5556
  /**
@@ -5171,7 +5660,7 @@ Copyright © 2023 37signals LLC
5171
5660
  PageRenderer: PageRenderer,
5172
5661
  PageSnapshot: PageSnapshot,
5173
5662
  FrameRenderer: FrameRenderer,
5174
- fetch: fetch,
5663
+ fetch: fetchWithTurboHeaders,
5175
5664
  start: start,
5176
5665
  registerAdapter: registerAdapter,
5177
5666
  visit: visit,
@@ -5419,7 +5908,7 @@ Copyright © 2023 37signals LLC
5419
5908
 
5420
5909
  // View delegate
5421
5910
 
5422
- allowsImmediateRender({ element: newFrame }, _isPreview, options) {
5911
+ allowsImmediateRender({ element: newFrame }, options) {
5423
5912
  const event = dispatch("turbo:before-frame-render", {
5424
5913
  target: this.element,
5425
5914
  detail: { newFrame, ...options },
@@ -6040,7 +6529,7 @@ Copyright © 2023 37signals LLC
6040
6529
  }
6041
6530
  })();
6042
6531
 
6043
- window.Turbo = Turbo;
6532
+ window.Turbo = { ...Turbo, StreamActions };
6044
6533
  start();
6045
6534
 
6046
6535
  exports.FetchEnctype = FetchEnctype;
@@ -6059,7 +6548,7 @@ Copyright © 2023 37signals LLC
6059
6548
  exports.clearCache = clearCache;
6060
6549
  exports.connectStreamSource = connectStreamSource;
6061
6550
  exports.disconnectStreamSource = disconnectStreamSource;
6062
- exports.fetch = fetch;
6551
+ exports.fetch = fetchWithTurboHeaders;
6063
6552
  exports.fetchEnctypeFromString = fetchEnctypeFromString;
6064
6553
  exports.fetchMethodFromString = fetchMethodFromString;
6065
6554
  exports.isSafe = isSafe;