@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
  /**
6
6
  * The MIT License (MIT)
@@ -633,13 +633,61 @@ async function around(callback, reader) {
633
633
  return [before, after]
634
634
  }
635
635
 
636
- function fetch(url, options = {}) {
636
+ function doesNotTargetIFrame(anchor) {
637
+ if (anchor.hasAttribute("target")) {
638
+ for (const element of document.getElementsByName(anchor.target)) {
639
+ if (element instanceof HTMLIFrameElement) return false
640
+ }
641
+ }
642
+
643
+ return true
644
+ }
645
+
646
+ function findLinkFromClickTarget(target) {
647
+ return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
648
+ }
649
+
650
+ function getLocationForLink(link) {
651
+ return expandURL(link.getAttribute("href") || "")
652
+ }
653
+
654
+ function debounce(fn, delay) {
655
+ let timeoutId = null;
656
+
657
+ return (...args) => {
658
+ const callback = () => fn.apply(this, args);
659
+ clearTimeout(timeoutId);
660
+ timeoutId = setTimeout(callback, delay);
661
+ }
662
+ }
663
+
664
+ class LimitedSet extends Set {
665
+ constructor(maxSize) {
666
+ super();
667
+ this.maxSize = maxSize;
668
+ }
669
+
670
+ add(value) {
671
+ if (this.size >= this.maxSize) {
672
+ const iterator = this.values();
673
+ const oldestValue = iterator.next().value;
674
+ this.delete(oldestValue);
675
+ }
676
+ super.add(value);
677
+ }
678
+ }
679
+
680
+ const recentRequests = new LimitedSet(20);
681
+
682
+ const nativeFetch = window.fetch;
683
+
684
+ function fetchWithTurboHeaders(url, options = {}) {
637
685
  const modifiedHeaders = new Headers(options.headers || {});
638
686
  const requestUID = uuid();
639
- window.Turbo.session.recentRequests.add(requestUID);
687
+ recentRequests.add(requestUID);
640
688
  modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
641
689
 
642
- return window.fetch(url, {
690
+ return nativeFetch(url, {
643
691
  ...options,
644
692
  headers: modifiedHeaders
645
693
  })
@@ -763,10 +811,17 @@ class FetchRequest {
763
811
  async perform() {
764
812
  const { fetchOptions } = this;
765
813
  this.delegate.prepareRequest(this);
766
- await this.#allowRequestToBeIntercepted(fetchOptions);
814
+ const event = await this.#allowRequestToBeIntercepted(fetchOptions);
767
815
  try {
768
816
  this.delegate.requestStarted(this);
769
- const response = await fetch(this.url.href, fetchOptions);
817
+
818
+ if (event.detail.fetchRequest) {
819
+ this.response = event.detail.fetchRequest.response;
820
+ } else {
821
+ this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
822
+ }
823
+
824
+ const response = await this.response;
770
825
  return await this.receive(response)
771
826
  } catch (error) {
772
827
  if (error.name !== "AbortError") {
@@ -828,6 +883,8 @@ class FetchRequest {
828
883
  });
829
884
  this.url = event.detail.url;
830
885
  if (event.defaultPrevented) await requestInterception;
886
+
887
+ return event
831
888
  }
832
889
 
833
890
  #willDelegateErrorHandling(error) {
@@ -938,6 +995,41 @@ function importStreamElements(fragment) {
938
995
  return fragment
939
996
  }
940
997
 
998
+ const PREFETCH_DELAY = 100;
999
+
1000
+ class PrefetchCache {
1001
+ #prefetchTimeout = null
1002
+ #prefetched = null
1003
+
1004
+ get(url) {
1005
+ if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
1006
+ return this.#prefetched.request
1007
+ }
1008
+ }
1009
+
1010
+ setLater(url, request, ttl) {
1011
+ this.clear();
1012
+
1013
+ this.#prefetchTimeout = setTimeout(() => {
1014
+ request.perform();
1015
+ this.set(url, request, ttl);
1016
+ this.#prefetchTimeout = null;
1017
+ }, PREFETCH_DELAY);
1018
+ }
1019
+
1020
+ set(url, request, ttl) {
1021
+ this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
1022
+ }
1023
+
1024
+ clear() {
1025
+ if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
1026
+ this.#prefetched = null;
1027
+ }
1028
+ }
1029
+
1030
+ const cacheTtl = 10 * 1000;
1031
+ const prefetchCache = new PrefetchCache();
1032
+
941
1033
  const FormSubmissionState = {
942
1034
  initialized: "initialized",
943
1035
  requesting: "requesting",
@@ -1046,6 +1138,7 @@ class FormSubmission {
1046
1138
  this.state = FormSubmissionState.waiting;
1047
1139
  this.submitter?.setAttribute("disabled", "");
1048
1140
  this.setSubmitsWith();
1141
+ markAsBusy(this.formElement);
1049
1142
  dispatch("turbo:submit-start", {
1050
1143
  target: this.formElement,
1051
1144
  detail: { formSubmission: this }
@@ -1054,13 +1147,20 @@ class FormSubmission {
1054
1147
  }
1055
1148
 
1056
1149
  requestPreventedHandlingResponse(request, response) {
1150
+ prefetchCache.clear();
1151
+
1057
1152
  this.result = { success: response.succeeded, fetchResponse: response };
1058
1153
  }
1059
1154
 
1060
1155
  requestSucceededWithResponse(request, response) {
1061
1156
  if (response.clientError || response.serverError) {
1062
1157
  this.delegate.formSubmissionFailedWithResponse(this, response);
1063
- } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1158
+ return
1159
+ }
1160
+
1161
+ prefetchCache.clear();
1162
+
1163
+ if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
1064
1164
  const error = new Error("Form responses must redirect to another location");
1065
1165
  this.delegate.formSubmissionErrored(this, error);
1066
1166
  } else {
@@ -1084,6 +1184,7 @@ class FormSubmission {
1084
1184
  this.state = FormSubmissionState.stopped;
1085
1185
  this.submitter?.removeAttribute("disabled");
1086
1186
  this.resetSubmitterText();
1187
+ clearBusyState(this.formElement);
1087
1188
  dispatch("turbo:submit-end", {
1088
1189
  target: this.formElement,
1089
1190
  detail: { formSubmission: this, ...this.result }
@@ -1377,7 +1478,7 @@ class View {
1377
1478
 
1378
1479
  const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
1379
1480
  const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement };
1380
- const immediateRender = this.delegate.allowsImmediateRender(snapshot, isPreview, options);
1481
+ const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
1381
1482
  if (!immediateRender) await renderInterception;
1382
1483
 
1383
1484
  await this.renderSnapshot(renderer);
@@ -1411,6 +1512,14 @@ class View {
1411
1512
  }
1412
1513
  }
1413
1514
 
1515
+ markVisitDirection(direction) {
1516
+ this.element.setAttribute("data-turbo-visit-direction", direction);
1517
+ }
1518
+
1519
+ unmarkVisitDirection() {
1520
+ this.element.removeAttribute("data-turbo-visit-direction");
1521
+ }
1522
+
1414
1523
  async renderSnapshot(renderer) {
1415
1524
  await renderer.render();
1416
1525
  }
@@ -1507,9 +1616,9 @@ class LinkClickObserver {
1507
1616
  clickBubbled = (event) => {
1508
1617
  if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
1509
1618
  const target = (event.composedPath && event.composedPath()[0]) || event.target;
1510
- const link = this.findLinkFromClickTarget(target);
1619
+ const link = findLinkFromClickTarget(target);
1511
1620
  if (link && doesNotTargetIFrame(link)) {
1512
- const location = this.getLocationForLink(link);
1621
+ const location = getLocationForLink(link);
1513
1622
  if (this.delegate.willFollowLinkToLocation(link, location, event)) {
1514
1623
  event.preventDefault();
1515
1624
  this.delegate.followedLinkToLocation(link, location);
@@ -1529,26 +1638,6 @@ class LinkClickObserver {
1529
1638
  event.shiftKey
1530
1639
  )
1531
1640
  }
1532
-
1533
- findLinkFromClickTarget(target) {
1534
- return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
1535
- }
1536
-
1537
- getLocationForLink(link) {
1538
- return expandURL(link.getAttribute("href") || "")
1539
- }
1540
- }
1541
-
1542
- function doesNotTargetIFrame(anchor) {
1543
- if (anchor.hasAttribute("target")) {
1544
- for (const element of document.getElementsByName(anchor.target)) {
1545
- if (element instanceof HTMLIFrameElement) return false
1546
- }
1547
-
1548
- return true
1549
- } else {
1550
- return true
1551
- }
1552
1641
  }
1553
1642
 
1554
1643
  class FormLinkClickObserver {
@@ -1565,6 +1654,16 @@ class FormLinkClickObserver {
1565
1654
  this.linkInterceptor.stop();
1566
1655
  }
1567
1656
 
1657
+ // Link hover observer delegate
1658
+
1659
+ canPrefetchRequestToLocation(link, location) {
1660
+ return false
1661
+ }
1662
+
1663
+ prefetchAndCacheRequestToLocation(link, location) {
1664
+ return
1665
+ }
1666
+
1568
1667
  // Link click observer delegate
1569
1668
 
1570
1669
  willFollowLinkToLocation(link, location, originalEvent) {
@@ -1780,14 +1879,14 @@ class FrameRenderer extends Renderer {
1780
1879
  }
1781
1880
 
1782
1881
  async render() {
1783
- await nextAnimationFrame();
1882
+ await nextRepaint();
1784
1883
  this.preservingPermanentElements(() => {
1785
1884
  this.loadFrameElement();
1786
1885
  });
1787
1886
  this.scrollFrameIntoView();
1788
- await nextAnimationFrame();
1887
+ await nextRepaint();
1789
1888
  this.focusFirstAutofocusableElement();
1790
- await nextAnimationFrame();
1889
+ await nextRepaint();
1791
1890
  this.activateScriptElements();
1792
1891
  }
1793
1892
 
@@ -1838,6 +1937,8 @@ function readScrollBehavior(value, defaultValue) {
1838
1937
  }
1839
1938
  }
1840
1939
 
1940
+ const ProgressBarID = "turbo-progress-bar";
1941
+
1841
1942
  class ProgressBar {
1842
1943
  static animationDuration = 300 /*ms*/
1843
1944
 
@@ -1942,6 +2043,8 @@ class ProgressBar {
1942
2043
 
1943
2044
  createStylesheetElement() {
1944
2045
  const element = document.createElement("style");
2046
+ element.id = ProgressBarID;
2047
+ element.setAttribute("data-turbo-permanent", "");
1945
2048
  element.type = "text/css";
1946
2049
  element.textContent = ProgressBar.defaultCSS;
1947
2050
  if (this.cspNonce) {
@@ -2213,6 +2316,12 @@ const SystemStatusCode = {
2213
2316
  contentTypeMismatch: -2
2214
2317
  };
2215
2318
 
2319
+ const Direction = {
2320
+ advance: "forward",
2321
+ restore: "back",
2322
+ replace: "none"
2323
+ };
2324
+
2216
2325
  class Visit {
2217
2326
  identifier = uuid() // Required by turbo-ios
2218
2327
  timingMetrics = {}
@@ -2242,7 +2351,8 @@ class Visit {
2242
2351
  willRender,
2243
2352
  updateHistory,
2244
2353
  shouldCacheSnapshot,
2245
- acceptsStreamResponse
2354
+ acceptsStreamResponse,
2355
+ direction
2246
2356
  } = {
2247
2357
  ...defaultOptions,
2248
2358
  ...options
@@ -2260,6 +2370,7 @@ class Visit {
2260
2370
  this.scrolled = !willRender;
2261
2371
  this.shouldCacheSnapshot = shouldCacheSnapshot;
2262
2372
  this.acceptsStreamResponse = acceptsStreamResponse;
2373
+ this.direction = direction || Direction[action];
2263
2374
  }
2264
2375
 
2265
2376
  get adapter() {
@@ -2512,7 +2623,7 @@ class Visit {
2512
2623
  // Scrolling
2513
2624
 
2514
2625
  performScroll() {
2515
- if (!this.scrolled && !this.view.forceReloaded && !this.view.snapshot.shouldPreserveScrollPosition) {
2626
+ if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) {
2516
2627
  if (this.action == "restore") {
2517
2628
  this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop();
2518
2629
  } else {
@@ -2587,9 +2698,7 @@ class Visit {
2587
2698
 
2588
2699
  async render(callback) {
2589
2700
  this.cancelRender();
2590
- await new Promise((resolve) => {
2591
- this.frame = requestAnimationFrame(() => resolve());
2592
- });
2701
+ this.frame = await nextRepaint();
2593
2702
  await callback();
2594
2703
  delete this.frame;
2595
2704
  }
@@ -2867,6 +2976,7 @@ class History {
2867
2976
  restorationData = {}
2868
2977
  started = false
2869
2978
  pageLoaded = false
2979
+ currentIndex = 0
2870
2980
 
2871
2981
  constructor(delegate) {
2872
2982
  this.delegate = delegate;
@@ -2876,6 +2986,7 @@ class History {
2876
2986
  if (!this.started) {
2877
2987
  addEventListener("popstate", this.onPopState, false);
2878
2988
  addEventListener("load", this.onPageLoad, false);
2989
+ this.currentIndex = history.state?.turbo?.restorationIndex || 0;
2879
2990
  this.started = true;
2880
2991
  this.replace(new URL(window.location.href));
2881
2992
  }
@@ -2898,7 +3009,9 @@ class History {
2898
3009
  }
2899
3010
 
2900
3011
  update(method, location, restorationIdentifier = uuid()) {
2901
- const state = { turbo: { restorationIdentifier } };
3012
+ if (method === history.pushState) ++this.currentIndex;
3013
+
3014
+ const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } };
2902
3015
  method.call(history, state, "", location.href);
2903
3016
  this.location = location;
2904
3017
  this.restorationIdentifier = restorationIdentifier;
@@ -2942,9 +3055,11 @@ class History {
2942
3055
  const { turbo } = event.state || {};
2943
3056
  if (turbo) {
2944
3057
  this.location = new URL(window.location.href);
2945
- const { restorationIdentifier } = turbo;
3058
+ const { restorationIdentifier, restorationIndex } = turbo;
2946
3059
  this.restorationIdentifier = restorationIdentifier;
2947
- this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier);
3060
+ const direction = restorationIndex > this.currentIndex ? "forward" : "back";
3061
+ this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
3062
+ this.currentIndex = restorationIndex;
2948
3063
  }
2949
3064
  }
2950
3065
  }
@@ -2966,6 +3081,176 @@ class History {
2966
3081
  }
2967
3082
  }
2968
3083
 
3084
+ class LinkPrefetchObserver {
3085
+ started = false
3086
+ hoverTriggerEvent = "mouseenter"
3087
+ touchTriggerEvent = "touchstart"
3088
+
3089
+ constructor(delegate, eventTarget) {
3090
+ this.delegate = delegate;
3091
+ this.eventTarget = eventTarget;
3092
+ }
3093
+
3094
+ start() {
3095
+ if (this.started) return
3096
+
3097
+ if (this.eventTarget.readyState === "loading") {
3098
+ this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true });
3099
+ } else {
3100
+ this.#enable();
3101
+ }
3102
+ }
3103
+
3104
+ stop() {
3105
+ if (!this.started) return
3106
+
3107
+ this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3108
+ capture: true,
3109
+ passive: true
3110
+ });
3111
+ this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3112
+ capture: true,
3113
+ passive: true
3114
+ });
3115
+ this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3116
+ this.started = false;
3117
+ }
3118
+
3119
+ #enable = () => {
3120
+ this.eventTarget.addEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
3121
+ capture: true,
3122
+ passive: true
3123
+ });
3124
+ this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
3125
+ capture: true,
3126
+ passive: true
3127
+ });
3128
+ this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
3129
+ this.started = true;
3130
+ }
3131
+
3132
+ #tryToPrefetchRequest = (event) => {
3133
+ if (getMetaContent("turbo-prefetch") !== "true") return
3134
+
3135
+ const target = event.target;
3136
+ const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])");
3137
+
3138
+ if (isLink && this.#isPrefetchable(target)) {
3139
+ const link = target;
3140
+ const location = getLocationForLink(link);
3141
+
3142
+ if (this.delegate.canPrefetchRequestToLocation(link, location)) {
3143
+ const fetchRequest = new FetchRequest(
3144
+ this,
3145
+ FetchMethod.get,
3146
+ location,
3147
+ new URLSearchParams(),
3148
+ target
3149
+ );
3150
+
3151
+ prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
3152
+
3153
+ link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true });
3154
+ }
3155
+ }
3156
+ }
3157
+
3158
+ #tryToUsePrefetchedRequest = (event) => {
3159
+ if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") {
3160
+ const cached = prefetchCache.get(event.detail.url.toString());
3161
+
3162
+ if (cached) {
3163
+ // User clicked link, use cache response
3164
+ event.detail.fetchRequest = cached;
3165
+ }
3166
+
3167
+ prefetchCache.clear();
3168
+ }
3169
+ }
3170
+
3171
+ prepareRequest(request) {
3172
+ const link = request.target;
3173
+
3174
+ request.headers["Sec-Purpose"] = "prefetch";
3175
+
3176
+ if (link.dataset.turboFrame && link.dataset.turboFrame !== "_top") {
3177
+ request.headers["Turbo-Frame"] = link.dataset.turboFrame;
3178
+ } else if (link.dataset.turboFrame !== "_top") {
3179
+ const turboFrame = link.closest("turbo-frame");
3180
+
3181
+ if (turboFrame) {
3182
+ request.headers["Turbo-Frame"] = turboFrame.id;
3183
+ }
3184
+ }
3185
+
3186
+ if (link.hasAttribute("data-turbo-stream")) {
3187
+ request.acceptResponseType("text/vnd.turbo-stream.html");
3188
+ }
3189
+ }
3190
+
3191
+ // Fetch request interface
3192
+
3193
+ requestSucceededWithResponse() {}
3194
+
3195
+ requestStarted(fetchRequest) {}
3196
+
3197
+ requestErrored(fetchRequest) {}
3198
+
3199
+ requestFinished(fetchRequest) {}
3200
+
3201
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
3202
+
3203
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
3204
+
3205
+ get #cacheTtl() {
3206
+ return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
3207
+ }
3208
+
3209
+ #isPrefetchable(link) {
3210
+ const href = link.getAttribute("href");
3211
+
3212
+ if (!href || href === "#" || link.dataset.turbo === "false" || link.dataset.turboPrefetch === "false") {
3213
+ return false
3214
+ }
3215
+
3216
+ if (link.origin !== document.location.origin) {
3217
+ return false
3218
+ }
3219
+
3220
+ if (!["http:", "https:"].includes(link.protocol)) {
3221
+ return false
3222
+ }
3223
+
3224
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3225
+ return false
3226
+ }
3227
+
3228
+ if (link.dataset.turboMethod && link.dataset.turboMethod !== "get") {
3229
+ return false
3230
+ }
3231
+
3232
+ if (targetsIframe(link)) {
3233
+ return false
3234
+ }
3235
+
3236
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
3237
+ return false
3238
+ }
3239
+
3240
+ const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]");
3241
+
3242
+ if (turboPrefetchParent && turboPrefetchParent.dataset.turboPrefetch === "false") {
3243
+ return false
3244
+ }
3245
+
3246
+ return true
3247
+ }
3248
+ }
3249
+
3250
+ const targetsIframe = (link) => {
3251
+ return !doesNotTargetIFrame(link)
3252
+ };
3253
+
2969
3254
  class Navigator {
2970
3255
  constructor(delegate) {
2971
3256
  this.delegate = delegate;
@@ -3281,7 +3566,7 @@ async function withAutofocusFromFragment(fragment, callback) {
3281
3566
  }
3282
3567
 
3283
3568
  callback();
3284
- await nextAnimationFrame();
3569
+ await nextRepaint();
3285
3570
 
3286
3571
  const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body;
3287
3572
 
@@ -3436,722 +3721,838 @@ class ErrorRenderer extends Renderer {
3436
3721
  }
3437
3722
  }
3438
3723
 
3439
- let EMPTY_SET = new Set();
3724
+ // base IIFE to define idiomorph
3725
+ var Idiomorph = (function () {
3440
3726
 
3441
- //=============================================================================
3442
- // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3443
- //=============================================================================
3444
- function morph(oldNode, newContent, config = {}) {
3727
+ //=============================================================================
3728
+ // AND NOW IT BEGINS...
3729
+ //=============================================================================
3730
+ let EMPTY_SET = new Set();
3445
3731
 
3446
- if (oldNode instanceof Document) {
3447
- oldNode = oldNode.documentElement;
3448
- }
3449
-
3450
- if (typeof newContent === 'string') {
3451
- newContent = parseContent(newContent);
3452
- }
3453
-
3454
- let normalizedContent = normalizeContent(newContent);
3455
-
3456
- let ctx = createMorphContext(oldNode, normalizedContent, config);
3457
-
3458
- return morphNormalizedContent(oldNode, normalizedContent, ctx);
3459
- }
3732
+ // default configuration values, updatable by users now
3733
+ let defaults = {
3734
+ morphStyle: "outerHTML",
3735
+ callbacks : {
3736
+ beforeNodeAdded: noOp,
3737
+ afterNodeAdded: noOp,
3738
+ beforeNodeMorphed: noOp,
3739
+ afterNodeMorphed: noOp,
3740
+ beforeNodeRemoved: noOp,
3741
+ afterNodeRemoved: noOp,
3742
+ beforeAttributeUpdated: noOp,
3460
3743
 
3461
- function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3462
- if (ctx.head.block) {
3463
- let oldHead = oldNode.querySelector('head');
3464
- let newHead = normalizedNewContent.querySelector('head');
3465
- if (oldHead && newHead) {
3466
- let promises = handleHeadElement(newHead, oldHead, ctx);
3467
- // when head promises resolve, call morph again, ignoring the head tag
3468
- Promise.all(promises).then(function () {
3469
- morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3470
- head: {
3471
- block: false,
3472
- ignore: true
3473
- }
3474
- }));
3475
- });
3476
- return;
3477
- }
3478
- }
3744
+ },
3745
+ head: {
3746
+ style: 'merge',
3747
+ shouldPreserve: function (elt) {
3748
+ return elt.getAttribute("im-preserve") === "true";
3749
+ },
3750
+ shouldReAppend: function (elt) {
3751
+ return elt.getAttribute("im-re-append") === "true";
3752
+ },
3753
+ shouldRemove: noOp,
3754
+ afterHeadMorphed: noOp,
3755
+ }
3756
+ };
3479
3757
 
3480
- if (ctx.morphStyle === "innerHTML") {
3758
+ //=============================================================================
3759
+ // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
3760
+ //=============================================================================
3761
+ function morph(oldNode, newContent, config = {}) {
3481
3762
 
3482
- // innerHTML, so we are only updating the children
3483
- morphChildren(normalizedNewContent, oldNode, ctx);
3484
- return oldNode.children;
3763
+ if (oldNode instanceof Document) {
3764
+ oldNode = oldNode.documentElement;
3765
+ }
3485
3766
 
3486
- } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3487
- // otherwise find the best element match in the new content, morph that, and merge its siblings
3488
- // into either side of the best match
3489
- let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3767
+ if (typeof newContent === 'string') {
3768
+ newContent = parseContent(newContent);
3769
+ }
3490
3770
 
3491
- // stash the siblings that will need to be inserted on either side of the best match
3492
- let previousSibling = bestMatch?.previousSibling;
3493
- let nextSibling = bestMatch?.nextSibling;
3771
+ let normalizedContent = normalizeContent(newContent);
3494
3772
 
3495
- // morph it
3496
- let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3773
+ let ctx = createMorphContext(oldNode, normalizedContent, config);
3497
3774
 
3498
- if (bestMatch) {
3499
- // if there was a best match, merge the siblings in too and return the
3500
- // whole bunch
3501
- return insertSiblings(previousSibling, morphedNode, nextSibling);
3502
- } else {
3503
- // otherwise nothing was added to the DOM
3504
- return []
3775
+ return morphNormalizedContent(oldNode, normalizedContent, ctx);
3505
3776
  }
3506
- } else {
3507
- throw "Do not understand how to morph style " + ctx.morphStyle;
3508
- }
3509
- }
3510
-
3511
3777
 
3778
+ function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
3779
+ if (ctx.head.block) {
3780
+ let oldHead = oldNode.querySelector('head');
3781
+ let newHead = normalizedNewContent.querySelector('head');
3782
+ if (oldHead && newHead) {
3783
+ let promises = handleHeadElement(newHead, oldHead, ctx);
3784
+ // when head promises resolve, call morph again, ignoring the head tag
3785
+ Promise.all(promises).then(function () {
3786
+ morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
3787
+ head: {
3788
+ block: false,
3789
+ ignore: true
3790
+ }
3791
+ }));
3792
+ });
3793
+ return;
3794
+ }
3795
+ }
3512
3796
 
3513
- /**
3514
- * @param oldNode root node to merge content into
3515
- * @param newContent new content to merge
3516
- * @param ctx the merge context
3517
- * @returns {Element} the element that ended up in the DOM
3518
- */
3519
- function morphOldNodeTo(oldNode, newContent, ctx) {
3520
- if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3521
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return;
3522
-
3523
- oldNode.remove();
3524
- ctx.callbacks.afterNodeRemoved(oldNode);
3525
- return null;
3526
- } else if (!isSoftMatch(oldNode, newContent)) {
3527
- if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return;
3528
- if (ctx.callbacks.beforeNodeAdded(newContent) === false) return;
3529
-
3530
- oldNode.parentElement.replaceChild(newContent, oldNode);
3531
- ctx.callbacks.afterNodeAdded(newContent);
3532
- ctx.callbacks.afterNodeRemoved(oldNode);
3533
- return newContent;
3534
- } else {
3535
- if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return;
3797
+ if (ctx.morphStyle === "innerHTML") {
3536
3798
 
3537
- if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3538
- handleHeadElement(newContent, oldNode, ctx);
3539
- } else {
3540
- syncNodeFrom(newContent, oldNode);
3541
- morphChildren(newContent, oldNode, ctx);
3542
- }
3543
- ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3544
- return oldNode;
3545
- }
3546
- }
3799
+ // innerHTML, so we are only updating the children
3800
+ morphChildren(normalizedNewContent, oldNode, ctx);
3801
+ return oldNode.children;
3547
3802
 
3548
- /**
3549
- * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3550
- * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3551
- * by using id sets, we are able to better match up with content deeper in the DOM.
3552
- *
3553
- * Basic algorithm is, for each node in the new content:
3554
- *
3555
- * - if we have reached the end of the old parent, append the new content
3556
- * - if the new content has an id set match with the current insertion point, morph
3557
- * - search for an id set match
3558
- * - if id set match found, morph
3559
- * - otherwise search for a "soft" match
3560
- * - if a soft match is found, morph
3561
- * - otherwise, prepend the new node before the current insertion point
3562
- *
3563
- * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3564
- * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3565
- *
3566
- * @param {Element} newParent the parent element of the new content
3567
- * @param {Element } oldParent the old content that we are merging the new content into
3568
- * @param ctx the merge context
3569
- */
3570
- function morphChildren(newParent, oldParent, ctx) {
3803
+ } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
3804
+ // otherwise find the best element match in the new content, morph that, and merge its siblings
3805
+ // into either side of the best match
3806
+ let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
3571
3807
 
3572
- let nextNewChild = newParent.firstChild;
3573
- let insertionPoint = oldParent.firstChild;
3574
- let newChild;
3808
+ // stash the siblings that will need to be inserted on either side of the best match
3809
+ let previousSibling = bestMatch?.previousSibling;
3810
+ let nextSibling = bestMatch?.nextSibling;
3575
3811
 
3576
- // run through all the new content
3577
- while (nextNewChild) {
3812
+ // morph it
3813
+ let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
3578
3814
 
3579
- newChild = nextNewChild;
3580
- nextNewChild = newChild.nextSibling;
3815
+ if (bestMatch) {
3816
+ // if there was a best match, merge the siblings in too and return the
3817
+ // whole bunch
3818
+ return insertSiblings(previousSibling, morphedNode, nextSibling);
3819
+ } else {
3820
+ // otherwise nothing was added to the DOM
3821
+ return []
3822
+ }
3823
+ } else {
3824
+ throw "Do not understand how to morph style " + ctx.morphStyle;
3825
+ }
3826
+ }
3581
3827
 
3582
- // if we are at the end of the exiting parent's children, just append
3583
- if (insertionPoint == null) {
3584
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3585
3828
 
3586
- oldParent.appendChild(newChild);
3587
- ctx.callbacks.afterNodeAdded(newChild);
3588
- removeIdsFromConsideration(ctx, newChild);
3589
- continue;
3829
+ /**
3830
+ * @param possibleActiveElement
3831
+ * @param ctx
3832
+ * @returns {boolean}
3833
+ */
3834
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
3835
+ return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
3590
3836
  }
3591
3837
 
3592
- // if the current node has an id set match then morph
3593
- if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3594
- morphOldNodeTo(insertionPoint, newChild, ctx);
3595
- insertionPoint = insertionPoint.nextSibling;
3596
- removeIdsFromConsideration(ctx, newChild);
3597
- continue;
3838
+ /**
3839
+ * @param oldNode root node to merge content into
3840
+ * @param newContent new content to merge
3841
+ * @param ctx the merge context
3842
+ * @returns {Element} the element that ended up in the DOM
3843
+ */
3844
+ function morphOldNodeTo(oldNode, newContent, ctx) {
3845
+ if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
3846
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3847
+
3848
+ oldNode.remove();
3849
+ ctx.callbacks.afterNodeRemoved(oldNode);
3850
+ return null;
3851
+ } else if (!isSoftMatch(oldNode, newContent)) {
3852
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
3853
+ if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
3854
+
3855
+ oldNode.parentElement.replaceChild(newContent, oldNode);
3856
+ ctx.callbacks.afterNodeAdded(newContent);
3857
+ ctx.callbacks.afterNodeRemoved(oldNode);
3858
+ return newContent;
3859
+ } else {
3860
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
3861
+
3862
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
3863
+ handleHeadElement(newContent, oldNode, ctx);
3864
+ } else {
3865
+ syncNodeFrom(newContent, oldNode, ctx);
3866
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
3867
+ morphChildren(newContent, oldNode, ctx);
3868
+ }
3869
+ }
3870
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
3871
+ return oldNode;
3872
+ }
3598
3873
  }
3599
3874
 
3600
- // otherwise search forward in the existing old children for an id set match
3601
- let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3875
+ /**
3876
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
3877
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
3878
+ * by using id sets, we are able to better match up with content deeper in the DOM.
3879
+ *
3880
+ * Basic algorithm is, for each node in the new content:
3881
+ *
3882
+ * - if we have reached the end of the old parent, append the new content
3883
+ * - if the new content has an id set match with the current insertion point, morph
3884
+ * - search for an id set match
3885
+ * - if id set match found, morph
3886
+ * - otherwise search for a "soft" match
3887
+ * - if a soft match is found, morph
3888
+ * - otherwise, prepend the new node before the current insertion point
3889
+ *
3890
+ * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
3891
+ * with the current node. See findIdSetMatch() and findSoftMatch() for details.
3892
+ *
3893
+ * @param {Element} newParent the parent element of the new content
3894
+ * @param {Element } oldParent the old content that we are merging the new content into
3895
+ * @param ctx the merge context
3896
+ */
3897
+ function morphChildren(newParent, oldParent, ctx) {
3898
+
3899
+ let nextNewChild = newParent.firstChild;
3900
+ let insertionPoint = oldParent.firstChild;
3901
+ let newChild;
3902
+
3903
+ // run through all the new content
3904
+ while (nextNewChild) {
3905
+
3906
+ newChild = nextNewChild;
3907
+ nextNewChild = newChild.nextSibling;
3908
+
3909
+ // if we are at the end of the exiting parent's children, just append
3910
+ if (insertionPoint == null) {
3911
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3912
+
3913
+ oldParent.appendChild(newChild);
3914
+ ctx.callbacks.afterNodeAdded(newChild);
3915
+ removeIdsFromConsideration(ctx, newChild);
3916
+ continue;
3917
+ }
3602
3918
 
3603
- // if we found a potential match, remove the nodes until that point and morph
3604
- if (idSetMatch) {
3605
- insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3606
- morphOldNodeTo(idSetMatch, newChild, ctx);
3607
- removeIdsFromConsideration(ctx, newChild);
3608
- continue;
3609
- }
3919
+ // if the current node has an id set match then morph
3920
+ if (isIdSetMatch(newChild, insertionPoint, ctx)) {
3921
+ morphOldNodeTo(insertionPoint, newChild, ctx);
3922
+ insertionPoint = insertionPoint.nextSibling;
3923
+ removeIdsFromConsideration(ctx, newChild);
3924
+ continue;
3925
+ }
3610
3926
 
3611
- // no id set match found, so scan forward for a soft match for the current node
3612
- let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3927
+ // otherwise search forward in the existing old children for an id set match
3928
+ let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3613
3929
 
3614
- // if we found a soft match for the current node, morph
3615
- if (softMatch) {
3616
- insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3617
- morphOldNodeTo(softMatch, newChild, ctx);
3618
- removeIdsFromConsideration(ctx, newChild);
3619
- continue;
3620
- }
3930
+ // if we found a potential match, remove the nodes until that point and morph
3931
+ if (idSetMatch) {
3932
+ insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
3933
+ morphOldNodeTo(idSetMatch, newChild, ctx);
3934
+ removeIdsFromConsideration(ctx, newChild);
3935
+ continue;
3936
+ }
3621
3937
 
3622
- // abandon all hope of morphing, just insert the new child before the insertion point
3623
- // and move on
3624
- if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3938
+ // no id set match found, so scan forward for a soft match for the current node
3939
+ let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
3625
3940
 
3626
- oldParent.insertBefore(newChild, insertionPoint);
3627
- ctx.callbacks.afterNodeAdded(newChild);
3628
- removeIdsFromConsideration(ctx, newChild);
3629
- }
3941
+ // if we found a soft match for the current node, morph
3942
+ if (softMatch) {
3943
+ insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
3944
+ morphOldNodeTo(softMatch, newChild, ctx);
3945
+ removeIdsFromConsideration(ctx, newChild);
3946
+ continue;
3947
+ }
3630
3948
 
3631
- // remove any remaining old nodes that didn't match up with new content
3632
- while (insertionPoint !== null) {
3949
+ // abandon all hope of morphing, just insert the new child before the insertion point
3950
+ // and move on
3951
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
3633
3952
 
3634
- let tempNode = insertionPoint;
3635
- insertionPoint = insertionPoint.nextSibling;
3636
- removeNode(tempNode, ctx);
3637
- }
3638
- }
3953
+ oldParent.insertBefore(newChild, insertionPoint);
3954
+ ctx.callbacks.afterNodeAdded(newChild);
3955
+ removeIdsFromConsideration(ctx, newChild);
3956
+ }
3639
3957
 
3640
- //=============================================================================
3641
- // Attribute Syncing Code
3642
- //=============================================================================
3958
+ // remove any remaining old nodes that didn't match up with new content
3959
+ while (insertionPoint !== null) {
3643
3960
 
3644
- /**
3645
- * syncs a given node with another node, copying over all attributes and
3646
- * inner element state from the 'from' node to the 'to' node
3647
- *
3648
- * @param {Element} from the element to copy attributes & state from
3649
- * @param {Element} to the element to copy attributes & state to
3650
- */
3651
- function syncNodeFrom(from, to) {
3652
- let type = from.nodeType;
3653
-
3654
- // if is an element type, sync the attributes from the
3655
- // new node into the new node
3656
- if (type === 1 /* element type */) {
3657
- const fromAttributes = from.attributes;
3658
- const toAttributes = to.attributes;
3659
- for (const fromAttribute of fromAttributes) {
3660
- if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
3661
- to.setAttribute(fromAttribute.name, fromAttribute.value);
3961
+ let tempNode = insertionPoint;
3962
+ insertionPoint = insertionPoint.nextSibling;
3963
+ removeNode(tempNode, ctx);
3662
3964
  }
3663
3965
  }
3664
- for (const toAttribute of toAttributes) {
3665
- if (!from.hasAttribute(toAttribute.name)) {
3666
- to.removeAttribute(toAttribute.name);
3966
+
3967
+ //=============================================================================
3968
+ // Attribute Syncing Code
3969
+ //=============================================================================
3970
+
3971
+ /**
3972
+ * @param attr {String} the attribute to be mutated
3973
+ * @param to {Element} the element that is going to be updated
3974
+ * @param updateType {("update"|"remove")}
3975
+ * @param ctx the merge context
3976
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
3977
+ */
3978
+ function ignoreAttribute(attr, to, updateType, ctx) {
3979
+ if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
3980
+ return true;
3667
3981
  }
3982
+ return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
3668
3983
  }
3669
- }
3670
3984
 
3671
- // sync text nodes
3672
- if (type === 8 /* comment */ || type === 3 /* text */) {
3673
- if (to.nodeValue !== from.nodeValue) {
3674
- to.nodeValue = from.nodeValue;
3675
- }
3676
- }
3985
+ /**
3986
+ * syncs a given node with another node, copying over all attributes and
3987
+ * inner element state from the 'from' node to the 'to' node
3988
+ *
3989
+ * @param {Element} from the element to copy attributes & state from
3990
+ * @param {Element} to the element to copy attributes & state to
3991
+ * @param ctx the merge context
3992
+ */
3993
+ function syncNodeFrom(from, to, ctx) {
3994
+ let type = from.nodeType;
3995
+
3996
+ // if is an element type, sync the attributes from the
3997
+ // new node into the new node
3998
+ if (type === 1 /* element type */) {
3999
+ const fromAttributes = from.attributes;
4000
+ const toAttributes = to.attributes;
4001
+ for (const fromAttribute of fromAttributes) {
4002
+ if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
4003
+ continue;
4004
+ }
4005
+ if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
4006
+ to.setAttribute(fromAttribute.name, fromAttribute.value);
4007
+ }
4008
+ }
4009
+ // iterate backwards to avoid skipping over items when a delete occurs
4010
+ for (let i = toAttributes.length - 1; 0 <= i; i--) {
4011
+ const toAttribute = toAttributes[i];
4012
+ if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
4013
+ continue;
4014
+ }
4015
+ if (!from.hasAttribute(toAttribute.name)) {
4016
+ to.removeAttribute(toAttribute.name);
4017
+ }
4018
+ }
4019
+ }
3677
4020
 
3678
- // NB: many bothans died to bring us information:
3679
- //
3680
- // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
3681
- // https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
3682
-
3683
- // sync input value
3684
- if (from instanceof HTMLInputElement &&
3685
- to instanceof HTMLInputElement &&
3686
- from.type !== 'file') {
3687
-
3688
- to.value = from.value || '';
3689
- syncAttribute(from, to, 'value');
3690
-
3691
- // sync boolean attributes
3692
- syncAttribute(from, to, 'checked');
3693
- syncAttribute(from, to, 'disabled');
3694
- } else if (from instanceof HTMLOptionElement) {
3695
- syncAttribute(from, to, 'selected');
3696
- } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
3697
- let fromValue = from.value;
3698
- let toValue = to.value;
3699
- if (fromValue !== toValue) {
3700
- to.value = fromValue;
3701
- }
3702
- if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
3703
- to.firstChild.nodeValue = fromValue;
3704
- }
3705
- }
3706
- }
4021
+ // sync text nodes
4022
+ if (type === 8 /* comment */ || type === 3 /* text */) {
4023
+ if (to.nodeValue !== from.nodeValue) {
4024
+ to.nodeValue = from.nodeValue;
4025
+ }
4026
+ }
3707
4027
 
3708
- function syncAttribute(from, to, attributeName) {
3709
- if (from[attributeName] !== to[attributeName]) {
3710
- if (from[attributeName]) {
3711
- to.setAttribute(attributeName, from[attributeName]);
3712
- } else {
3713
- to.removeAttribute(attributeName);
4028
+ if (!ignoreValueOfActiveElement(to, ctx)) {
4029
+ // sync input values
4030
+ syncInputValue(from, to, ctx);
4031
+ }
3714
4032
  }
3715
- }
3716
- }
3717
4033
 
3718
- //=============================================================================
3719
- // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
3720
- //=============================================================================
3721
- function handleHeadElement(newHeadTag, currentHead, ctx) {
4034
+ /**
4035
+ * @param from {Element} element to sync the value from
4036
+ * @param to {Element} element to sync the value to
4037
+ * @param attributeName {String} the attribute name
4038
+ * @param ctx the merge context
4039
+ */
4040
+ function syncBooleanAttribute(from, to, attributeName, ctx) {
4041
+ if (from[attributeName] !== to[attributeName]) {
4042
+ let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
4043
+ if (!ignoreUpdate) {
4044
+ to[attributeName] = from[attributeName];
4045
+ }
4046
+ if (from[attributeName]) {
4047
+ if (!ignoreUpdate) {
4048
+ to.setAttribute(attributeName, from[attributeName]);
4049
+ }
4050
+ } else {
4051
+ if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
4052
+ to.removeAttribute(attributeName);
4053
+ }
4054
+ }
4055
+ }
4056
+ }
3722
4057
 
3723
- let added = [];
3724
- let removed = [];
3725
- let preserved = [];
3726
- let nodesToAppend = [];
4058
+ /**
4059
+ * NB: many bothans died to bring us information:
4060
+ *
4061
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
4062
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
4063
+ *
4064
+ * @param from {Element} the element to sync the input value from
4065
+ * @param to {Element} the element to sync the input value to
4066
+ * @param ctx the merge context
4067
+ */
4068
+ function syncInputValue(from, to, ctx) {
4069
+ if (from instanceof HTMLInputElement &&
4070
+ to instanceof HTMLInputElement &&
4071
+ from.type !== 'file') {
4072
+
4073
+ let fromValue = from.value;
4074
+ let toValue = to.value;
4075
+
4076
+ // sync boolean attributes
4077
+ syncBooleanAttribute(from, to, 'checked', ctx);
4078
+ syncBooleanAttribute(from, to, 'disabled', ctx);
4079
+
4080
+ if (!from.hasAttribute('value')) {
4081
+ if (!ignoreAttribute('value', to, 'remove', ctx)) {
4082
+ to.value = '';
4083
+ to.removeAttribute('value');
4084
+ }
4085
+ } else if (fromValue !== toValue) {
4086
+ if (!ignoreAttribute('value', to, 'update', ctx)) {
4087
+ to.setAttribute('value', fromValue);
4088
+ to.value = fromValue;
4089
+ }
4090
+ }
4091
+ } else if (from instanceof HTMLOptionElement) {
4092
+ syncBooleanAttribute(from, to, 'selected', ctx);
4093
+ } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
4094
+ let fromValue = from.value;
4095
+ let toValue = to.value;
4096
+ if (ignoreAttribute('value', to, 'update', ctx)) {
4097
+ return;
4098
+ }
4099
+ if (fromValue !== toValue) {
4100
+ to.value = fromValue;
4101
+ }
4102
+ if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
4103
+ to.firstChild.nodeValue = fromValue;
4104
+ }
4105
+ }
4106
+ }
3727
4107
 
3728
- let headMergeStyle = ctx.head.style;
4108
+ //=============================================================================
4109
+ // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
4110
+ //=============================================================================
4111
+ function handleHeadElement(newHeadTag, currentHead, ctx) {
3729
4112
 
3730
- // put all new head elements into a Map, by their outerHTML
3731
- let srcToNewHeadNodes = new Map();
3732
- for (const newHeadChild of newHeadTag.children) {
3733
- srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3734
- }
4113
+ let added = [];
4114
+ let removed = [];
4115
+ let preserved = [];
4116
+ let nodesToAppend = [];
3735
4117
 
3736
- // for each elt in the current head
3737
- for (const currentHeadElt of currentHead.children) {
4118
+ let headMergeStyle = ctx.head.style;
3738
4119
 
3739
- // If the current head element is in the map
3740
- let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
3741
- let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
3742
- let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
3743
- if (inNewContent || isPreserved) {
3744
- if (isReAppended) {
3745
- // remove the current version and let the new version replace it and re-execute
3746
- removed.push(currentHeadElt);
3747
- } else {
3748
- // this element already exists and should not be re-appended, so remove it from
3749
- // the new content map, preserving it in the DOM
3750
- srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
3751
- preserved.push(currentHeadElt);
4120
+ // put all new head elements into a Map, by their outerHTML
4121
+ let srcToNewHeadNodes = new Map();
4122
+ for (const newHeadChild of newHeadTag.children) {
4123
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
3752
4124
  }
3753
- } else {
3754
- if (headMergeStyle === "append") {
3755
- // we are appending and this existing element is not new content
3756
- // so if and only if it is marked for re-append do we do anything
3757
- if (isReAppended) {
3758
- removed.push(currentHeadElt);
3759
- nodesToAppend.push(currentHeadElt);
4125
+
4126
+ // for each elt in the current head
4127
+ for (const currentHeadElt of currentHead.children) {
4128
+
4129
+ // If the current head element is in the map
4130
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
4131
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
4132
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
4133
+ if (inNewContent || isPreserved) {
4134
+ if (isReAppended) {
4135
+ // remove the current version and let the new version replace it and re-execute
4136
+ removed.push(currentHeadElt);
4137
+ } else {
4138
+ // this element already exists and should not be re-appended, so remove it from
4139
+ // the new content map, preserving it in the DOM
4140
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
4141
+ preserved.push(currentHeadElt);
4142
+ }
4143
+ } else {
4144
+ if (headMergeStyle === "append") {
4145
+ // we are appending and this existing element is not new content
4146
+ // so if and only if it is marked for re-append do we do anything
4147
+ if (isReAppended) {
4148
+ removed.push(currentHeadElt);
4149
+ nodesToAppend.push(currentHeadElt);
4150
+ }
4151
+ } else {
4152
+ // if this is a merge, we remove this content since it is not in the new head
4153
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
4154
+ removed.push(currentHeadElt);
4155
+ }
4156
+ }
3760
4157
  }
3761
- } else {
3762
- // if this is a merge, we remove this content since it is not in the new head
3763
- if (ctx.head.shouldRemove(currentHeadElt) !== false) {
3764
- removed.push(currentHeadElt);
4158
+ }
4159
+
4160
+ // Push the remaining new head elements in the Map into the
4161
+ // nodes to append to the head tag
4162
+ nodesToAppend.push(...srcToNewHeadNodes.values());
4163
+
4164
+ let promises = [];
4165
+ for (const newNode of nodesToAppend) {
4166
+ let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
4167
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
4168
+ if (newElt.href || newElt.src) {
4169
+ let resolve = null;
4170
+ let promise = new Promise(function (_resolve) {
4171
+ resolve = _resolve;
4172
+ });
4173
+ newElt.addEventListener('load', function () {
4174
+ resolve();
4175
+ });
4176
+ promises.push(promise);
4177
+ }
4178
+ currentHead.appendChild(newElt);
4179
+ ctx.callbacks.afterNodeAdded(newElt);
4180
+ added.push(newElt);
3765
4181
  }
3766
4182
  }
3767
- }
3768
- }
3769
4183
 
3770
- // Push the remaining new head elements in the Map into the
3771
- // nodes to append to the head tag
3772
- nodesToAppend.push(...srcToNewHeadNodes.values());
3773
-
3774
- let promises = [];
3775
- for (const newNode of nodesToAppend) {
3776
- let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
3777
- if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
3778
- if (newElt.href || newElt.src) {
3779
- let resolve = null;
3780
- let promise = new Promise(function (_resolve) {
3781
- resolve = _resolve;
3782
- });
3783
- newElt.addEventListener('load',function() {
3784
- resolve();
3785
- });
3786
- promises.push(promise);
4184
+ // remove all removed elements, after we have appended the new elements to avoid
4185
+ // additional network requests for things like style sheets
4186
+ for (const removedElement of removed) {
4187
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
4188
+ currentHead.removeChild(removedElement);
4189
+ ctx.callbacks.afterNodeRemoved(removedElement);
4190
+ }
3787
4191
  }
3788
- currentHead.appendChild(newElt);
3789
- ctx.callbacks.afterNodeAdded(newElt);
3790
- added.push(newElt);
3791
- }
3792
- }
3793
4192
 
3794
- // remove all removed elements, after we have appended the new elements to avoid
3795
- // additional network requests for things like style sheets
3796
- for (const removedElement of removed) {
3797
- if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
3798
- currentHead.removeChild(removedElement);
3799
- ctx.callbacks.afterNodeRemoved(removedElement);
4193
+ ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
4194
+ return promises;
3800
4195
  }
3801
- }
3802
4196
 
3803
- ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
3804
- return promises;
3805
- }
4197
+ function noOp() {
4198
+ }
3806
4199
 
3807
- function noOp() {}
4200
+ /*
4201
+ Deep merges the config object and the Idiomoroph.defaults object to
4202
+ produce a final configuration object
4203
+ */
4204
+ function mergeDefaults(config) {
4205
+ let finalConfig = {};
4206
+ // copy top level stuff into final config
4207
+ Object.assign(finalConfig, defaults);
4208
+ Object.assign(finalConfig, config);
4209
+
4210
+ // copy callbacks into final config (do this to deep merge the callbacks)
4211
+ finalConfig.callbacks = {};
4212
+ Object.assign(finalConfig.callbacks, defaults.callbacks);
4213
+ Object.assign(finalConfig.callbacks, config.callbacks);
4214
+
4215
+ // copy head config into final config (do this to deep merge the head)
4216
+ finalConfig.head = {};
4217
+ Object.assign(finalConfig.head, defaults.head);
4218
+ Object.assign(finalConfig.head, config.head);
4219
+ return finalConfig;
4220
+ }
3808
4221
 
3809
- function createMorphContext(oldNode, newContent, config) {
3810
- return {
3811
- target:oldNode,
3812
- newContent: newContent,
3813
- config: config,
3814
- morphStyle : config.morphStyle,
3815
- ignoreActive : config.ignoreActive,
3816
- idMap: createIdMap(oldNode, newContent),
3817
- deadIds: new Set(),
3818
- callbacks: Object.assign({
3819
- beforeNodeAdded: noOp,
3820
- afterNodeAdded : noOp,
3821
- beforeNodeMorphed: noOp,
3822
- afterNodeMorphed : noOp,
3823
- beforeNodeRemoved: noOp,
3824
- afterNodeRemoved : noOp,
3825
-
3826
- }, config.callbacks),
3827
- head: Object.assign({
3828
- style: 'merge',
3829
- shouldPreserve : function(elt) {
3830
- return elt.getAttribute("im-preserve") === "true";
3831
- },
3832
- shouldReAppend : function(elt) {
3833
- return elt.getAttribute("im-re-append") === "true";
3834
- },
3835
- shouldRemove : noOp,
3836
- afterHeadMorphed : noOp,
3837
- }, config.head),
3838
- }
3839
- }
4222
+ function createMorphContext(oldNode, newContent, config) {
4223
+ config = mergeDefaults(config);
4224
+ return {
4225
+ target: oldNode,
4226
+ newContent: newContent,
4227
+ config: config,
4228
+ morphStyle: config.morphStyle,
4229
+ ignoreActive: config.ignoreActive,
4230
+ ignoreActiveValue: config.ignoreActiveValue,
4231
+ idMap: createIdMap(oldNode, newContent),
4232
+ deadIds: new Set(),
4233
+ callbacks: config.callbacks,
4234
+ head: config.head
4235
+ }
4236
+ }
3840
4237
 
3841
- function isIdSetMatch(node1, node2, ctx) {
3842
- if (node1 == null || node2 == null) {
3843
- return false;
3844
- }
3845
- if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
3846
- if (node1.id !== "" && node1.id === node2.id) {
3847
- return true;
3848
- } else {
3849
- return getIdIntersectionCount(ctx, node1, node2) > 0;
4238
+ function isIdSetMatch(node1, node2, ctx) {
4239
+ if (node1 == null || node2 == null) {
4240
+ return false;
4241
+ }
4242
+ if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
4243
+ if (node1.id !== "" && node1.id === node2.id) {
4244
+ return true;
4245
+ } else {
4246
+ return getIdIntersectionCount(ctx, node1, node2) > 0;
4247
+ }
4248
+ }
4249
+ return false;
3850
4250
  }
3851
- }
3852
- return false;
3853
- }
3854
4251
 
3855
- function isSoftMatch(node1, node2) {
3856
- if (node1 == null || node2 == null) {
3857
- return false;
3858
- }
3859
- return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
3860
- }
4252
+ function isSoftMatch(node1, node2) {
4253
+ if (node1 == null || node2 == null) {
4254
+ return false;
4255
+ }
4256
+ return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
4257
+ }
3861
4258
 
3862
- function removeNodesBetween(startInclusive, endExclusive, ctx) {
3863
- while (startInclusive !== endExclusive) {
3864
- let tempNode = startInclusive;
3865
- startInclusive = startInclusive.nextSibling;
3866
- removeNode(tempNode, ctx);
3867
- }
3868
- removeIdsFromConsideration(ctx, endExclusive);
3869
- return endExclusive.nextSibling;
3870
- }
4259
+ function removeNodesBetween(startInclusive, endExclusive, ctx) {
4260
+ while (startInclusive !== endExclusive) {
4261
+ let tempNode = startInclusive;
4262
+ startInclusive = startInclusive.nextSibling;
4263
+ removeNode(tempNode, ctx);
4264
+ }
4265
+ removeIdsFromConsideration(ctx, endExclusive);
4266
+ return endExclusive.nextSibling;
4267
+ }
3871
4268
 
3872
- //=============================================================================
3873
- // Scans forward from the insertionPoint in the old parent looking for a potential id match
3874
- // for the newChild. We stop if we find a potential id match for the new child OR
3875
- // if the number of potential id matches we are discarding is greater than the
3876
- // potential id matches for the new child
3877
- //=============================================================================
3878
- function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4269
+ //=============================================================================
4270
+ // Scans forward from the insertionPoint in the old parent looking for a potential id match
4271
+ // for the newChild. We stop if we find a potential id match for the new child OR
4272
+ // if the number of potential id matches we are discarding is greater than the
4273
+ // potential id matches for the new child
4274
+ //=============================================================================
4275
+ function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4276
+
4277
+ // max id matches we are willing to discard in our search
4278
+ let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4279
+
4280
+ let potentialMatch = null;
4281
+
4282
+ // only search forward if there is a possibility of an id match
4283
+ if (newChildPotentialIdCount > 0) {
4284
+ let potentialMatch = insertionPoint;
4285
+ // if there is a possibility of an id match, scan forward
4286
+ // keep track of the potential id match count we are discarding (the
4287
+ // newChildPotentialIdCount must be greater than this to make it likely
4288
+ // worth it)
4289
+ let otherMatchCount = 0;
4290
+ while (potentialMatch != null) {
4291
+
4292
+ // If we have an id match, return the current potential match
4293
+ if (isIdSetMatch(newChild, potentialMatch, ctx)) {
4294
+ return potentialMatch;
4295
+ }
3879
4296
 
3880
- // max id matches we are willing to discard in our search
3881
- let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
4297
+ // computer the other potential matches of this new content
4298
+ otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
4299
+ if (otherMatchCount > newChildPotentialIdCount) {
4300
+ // if we have more potential id matches in _other_ content, we
4301
+ // do not have a good candidate for an id match, so return null
4302
+ return null;
4303
+ }
3882
4304
 
3883
- let potentialMatch = null;
4305
+ // advanced to the next old content child
4306
+ potentialMatch = potentialMatch.nextSibling;
4307
+ }
4308
+ }
4309
+ return potentialMatch;
4310
+ }
3884
4311
 
3885
- // only search forward if there is a possibility of an id match
3886
- if (newChildPotentialIdCount > 0) {
3887
- let potentialMatch = insertionPoint;
3888
- // if there is a possibility of an id match, scan forward
3889
- // keep track of the potential id match count we are discarding (the
3890
- // newChildPotentialIdCount must be greater than this to make it likely
3891
- // worth it)
3892
- let otherMatchCount = 0;
3893
- while (potentialMatch != null) {
4312
+ //=============================================================================
4313
+ // Scans forward from the insertionPoint in the old parent looking for a potential soft match
4314
+ // for the newChild. We stop if we find a potential soft match for the new child OR
4315
+ // if we find a potential id match in the old parents children OR if we find two
4316
+ // potential soft matches for the next two pieces of new content
4317
+ //=============================================================================
4318
+ function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
3894
4319
 
3895
- // If we have an id match, return the current potential match
3896
- if (isIdSetMatch(newChild, potentialMatch, ctx)) {
3897
- return potentialMatch;
3898
- }
4320
+ let potentialSoftMatch = insertionPoint;
4321
+ let nextSibling = newChild.nextSibling;
4322
+ let siblingSoftMatchCount = 0;
3899
4323
 
3900
- // computer the other potential matches of this new content
3901
- otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
3902
- if (otherMatchCount > newChildPotentialIdCount) {
3903
- // if we have more potential id matches in _other_ content, we
3904
- // do not have a good candidate for an id match, so return null
3905
- return null;
3906
- }
4324
+ while (potentialSoftMatch != null) {
3907
4325
 
3908
- // advanced to the next old content child
3909
- potentialMatch = potentialMatch.nextSibling;
3910
- }
3911
- }
3912
- return potentialMatch;
3913
- }
4326
+ if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
4327
+ // the current potential soft match has a potential id set match with the remaining new
4328
+ // content so bail out of looking
4329
+ return null;
4330
+ }
3914
4331
 
3915
- //=============================================================================
3916
- // Scans forward from the insertionPoint in the old parent looking for a potential soft match
3917
- // for the newChild. We stop if we find a potential soft match for the new child OR
3918
- // if we find a potential id match in the old parents children OR if we find two
3919
- // potential soft matches for the next two pieces of new content
3920
- //=============================================================================
3921
- function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
4332
+ // if we have a soft match with the current node, return it
4333
+ if (isSoftMatch(newChild, potentialSoftMatch)) {
4334
+ return potentialSoftMatch;
4335
+ }
3922
4336
 
3923
- let potentialSoftMatch = insertionPoint;
3924
- let nextSibling = newChild.nextSibling;
3925
- let siblingSoftMatchCount = 0;
4337
+ if (isSoftMatch(nextSibling, potentialSoftMatch)) {
4338
+ // the next new node has a soft match with this node, so
4339
+ // increment the count of future soft matches
4340
+ siblingSoftMatchCount++;
4341
+ nextSibling = nextSibling.nextSibling;
3926
4342
 
3927
- while (potentialSoftMatch != null) {
4343
+ // If there are two future soft matches, bail to allow the siblings to soft match
4344
+ // so that we don't consume future soft matches for the sake of the current node
4345
+ if (siblingSoftMatchCount >= 2) {
4346
+ return null;
4347
+ }
4348
+ }
3928
4349
 
3929
- if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
3930
- // the current potential soft match has a potential id set match with the remaining new
3931
- // content so bail out of looking
3932
- return null;
3933
- }
4350
+ // advanced to the next old content child
4351
+ potentialSoftMatch = potentialSoftMatch.nextSibling;
4352
+ }
3934
4353
 
3935
- // if we have a soft match with the current node, return it
3936
- if (isSoftMatch(newChild, potentialSoftMatch)) {
3937
4354
  return potentialSoftMatch;
3938
4355
  }
3939
4356
 
3940
- if (isSoftMatch(nextSibling, potentialSoftMatch)) {
3941
- // the next new node has a soft match with this node, so
3942
- // increment the count of future soft matches
3943
- siblingSoftMatchCount++;
3944
- nextSibling = nextSibling.nextSibling;
3945
-
3946
- // If there are two future soft matches, bail to allow the siblings to soft match
3947
- // so that we don't consume future soft matches for the sake of the current node
3948
- if (siblingSoftMatchCount >= 2) {
3949
- return null;
4357
+ function parseContent(newContent) {
4358
+ let parser = new DOMParser();
4359
+
4360
+ // remove svgs to avoid false-positive matches on head, etc.
4361
+ let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
4362
+
4363
+ // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
4364
+ if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
4365
+ let content = parser.parseFromString(newContent, "text/html");
4366
+ // if it is a full HTML document, return the document itself as the parent container
4367
+ if (contentWithSvgsRemoved.match(/<\/html>/)) {
4368
+ content.generatedByIdiomorph = true;
4369
+ return content;
4370
+ } else {
4371
+ // otherwise return the html element as the parent container
4372
+ let htmlElement = content.firstChild;
4373
+ if (htmlElement) {
4374
+ htmlElement.generatedByIdiomorph = true;
4375
+ return htmlElement;
4376
+ } else {
4377
+ return null;
4378
+ }
4379
+ }
4380
+ } else {
4381
+ // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
4382
+ // deal with touchy tags like tr, tbody, etc.
4383
+ let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
4384
+ let content = responseDoc.body.querySelector('template').content;
4385
+ content.generatedByIdiomorph = true;
4386
+ return content
3950
4387
  }
3951
4388
  }
3952
4389
 
3953
- // advanced to the next old content child
3954
- potentialSoftMatch = potentialSoftMatch.nextSibling;
3955
- }
3956
-
3957
- return potentialSoftMatch;
3958
- }
3959
-
3960
- function parseContent(newContent) {
3961
- let parser = new DOMParser();
3962
-
3963
- // remove svgs to avoid false-positive matches on head, etc.
3964
- let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
3965
-
3966
- // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
3967
- if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
3968
- let content = parser.parseFromString(newContent, "text/html");
3969
- // if it is a full HTML document, return the document itself as the parent container
3970
- if (contentWithSvgsRemoved.match(/<\/html>/)) {
3971
- content.generatedByIdiomorph = true;
3972
- return content;
3973
- } else {
3974
- // otherwise return the html element as the parent container
3975
- let htmlElement = content.firstChild;
3976
- if (htmlElement) {
3977
- htmlElement.generatedByIdiomorph = true;
3978
- return htmlElement;
4390
+ function normalizeContent(newContent) {
4391
+ if (newContent == null) {
4392
+ // noinspection UnnecessaryLocalVariableJS
4393
+ const dummyParent = document.createElement('div');
4394
+ return dummyParent;
4395
+ } else if (newContent.generatedByIdiomorph) {
4396
+ // the template tag created by idiomorph parsing can serve as a dummy parent
4397
+ return newContent;
4398
+ } else if (newContent instanceof Node) {
4399
+ // a single node is added as a child to a dummy parent
4400
+ const dummyParent = document.createElement('div');
4401
+ dummyParent.append(newContent);
4402
+ return dummyParent;
3979
4403
  } else {
3980
- return null;
4404
+ // all nodes in the array or HTMLElement collection are consolidated under
4405
+ // a single dummy parent element
4406
+ const dummyParent = document.createElement('div');
4407
+ for (const elt of [...newContent]) {
4408
+ dummyParent.append(elt);
4409
+ }
4410
+ return dummyParent;
3981
4411
  }
3982
4412
  }
3983
- } else {
3984
- // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
3985
- // deal with touchy tags like tr, tbody, etc.
3986
- let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
3987
- let content = responseDoc.body.querySelector('template').content;
3988
- content.generatedByIdiomorph = true;
3989
- return content
3990
- }
3991
- }
3992
-
3993
- function normalizeContent(newContent) {
3994
- if (newContent == null) {
3995
- // noinspection UnnecessaryLocalVariableJS
3996
- const dummyParent = document.createElement('div');
3997
- return dummyParent;
3998
- } else if (newContent.generatedByIdiomorph) {
3999
- // the template tag created by idiomorph parsing can serve as a dummy parent
4000
- return newContent;
4001
- } else if (newContent instanceof Node) {
4002
- // a single node is added as a child to a dummy parent
4003
- const dummyParent = document.createElement('div');
4004
- dummyParent.append(newContent);
4005
- return dummyParent;
4006
- } else {
4007
- // all nodes in the array or HTMLElement collection are consolidated under
4008
- // a single dummy parent element
4009
- const dummyParent = document.createElement('div');
4010
- for (const elt of [...newContent]) {
4011
- dummyParent.append(elt);
4012
- }
4013
- return dummyParent;
4014
- }
4015
- }
4016
4413
 
4017
- function insertSiblings(previousSibling, morphedNode, nextSibling) {
4018
- let stack = [];
4019
- let added = [];
4020
- while (previousSibling != null) {
4021
- stack.push(previousSibling);
4022
- previousSibling = previousSibling.previousSibling;
4023
- }
4024
- while (stack.length > 0) {
4025
- let node = stack.pop();
4026
- added.push(node); // push added preceding siblings on in order and insert
4027
- morphedNode.parentElement.insertBefore(node, morphedNode);
4028
- }
4029
- added.push(morphedNode);
4030
- while (nextSibling != null) {
4031
- stack.push(nextSibling);
4032
- added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4033
- nextSibling = nextSibling.nextSibling;
4034
- }
4035
- while (stack.length > 0) {
4036
- morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4037
- }
4038
- return added;
4039
- }
4414
+ function insertSiblings(previousSibling, morphedNode, nextSibling) {
4415
+ let stack = [];
4416
+ let added = [];
4417
+ while (previousSibling != null) {
4418
+ stack.push(previousSibling);
4419
+ previousSibling = previousSibling.previousSibling;
4420
+ }
4421
+ while (stack.length > 0) {
4422
+ let node = stack.pop();
4423
+ added.push(node); // push added preceding siblings on in order and insert
4424
+ morphedNode.parentElement.insertBefore(node, morphedNode);
4425
+ }
4426
+ added.push(morphedNode);
4427
+ while (nextSibling != null) {
4428
+ stack.push(nextSibling);
4429
+ added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
4430
+ nextSibling = nextSibling.nextSibling;
4431
+ }
4432
+ while (stack.length > 0) {
4433
+ morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
4434
+ }
4435
+ return added;
4436
+ }
4040
4437
 
4041
- function findBestNodeMatch(newContent, oldNode, ctx) {
4042
- let currentElement;
4043
- currentElement = newContent.firstChild;
4044
- let bestElement = currentElement;
4045
- let score = 0;
4046
- while (currentElement) {
4047
- let newScore = scoreElement(currentElement, oldNode, ctx);
4048
- if (newScore > score) {
4049
- bestElement = currentElement;
4050
- score = newScore;
4438
+ function findBestNodeMatch(newContent, oldNode, ctx) {
4439
+ let currentElement;
4440
+ currentElement = newContent.firstChild;
4441
+ let bestElement = currentElement;
4442
+ let score = 0;
4443
+ while (currentElement) {
4444
+ let newScore = scoreElement(currentElement, oldNode, ctx);
4445
+ if (newScore > score) {
4446
+ bestElement = currentElement;
4447
+ score = newScore;
4448
+ }
4449
+ currentElement = currentElement.nextSibling;
4450
+ }
4451
+ return bestElement;
4051
4452
  }
4052
- currentElement = currentElement.nextSibling;
4053
- }
4054
- return bestElement;
4055
- }
4056
4453
 
4057
- function scoreElement(node1, node2, ctx) {
4058
- if (isSoftMatch(node1, node2)) {
4059
- return .5 + getIdIntersectionCount(ctx, node1, node2);
4060
- }
4061
- return 0;
4062
- }
4454
+ function scoreElement(node1, node2, ctx) {
4455
+ if (isSoftMatch(node1, node2)) {
4456
+ return .5 + getIdIntersectionCount(ctx, node1, node2);
4457
+ }
4458
+ return 0;
4459
+ }
4063
4460
 
4064
- function removeNode(tempNode, ctx) {
4065
- removeIdsFromConsideration(ctx, tempNode);
4066
- if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4461
+ function removeNode(tempNode, ctx) {
4462
+ removeIdsFromConsideration(ctx, tempNode);
4463
+ if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
4067
4464
 
4068
- tempNode.remove();
4069
- ctx.callbacks.afterNodeRemoved(tempNode);
4070
- }
4465
+ tempNode.remove();
4466
+ ctx.callbacks.afterNodeRemoved(tempNode);
4467
+ }
4071
4468
 
4072
- //=============================================================================
4073
- // ID Set Functions
4074
- //=============================================================================
4469
+ //=============================================================================
4470
+ // ID Set Functions
4471
+ //=============================================================================
4075
4472
 
4076
- function isIdInConsideration(ctx, id) {
4077
- return !ctx.deadIds.has(id);
4078
- }
4473
+ function isIdInConsideration(ctx, id) {
4474
+ return !ctx.deadIds.has(id);
4475
+ }
4079
4476
 
4080
- function idIsWithinNode(ctx, id, targetNode) {
4081
- let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4082
- return idSet.has(id);
4083
- }
4477
+ function idIsWithinNode(ctx, id, targetNode) {
4478
+ let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
4479
+ return idSet.has(id);
4480
+ }
4084
4481
 
4085
- function removeIdsFromConsideration(ctx, node) {
4086
- let idSet = ctx.idMap.get(node) || EMPTY_SET;
4087
- for (const id of idSet) {
4088
- ctx.deadIds.add(id);
4089
- }
4090
- }
4482
+ function removeIdsFromConsideration(ctx, node) {
4483
+ let idSet = ctx.idMap.get(node) || EMPTY_SET;
4484
+ for (const id of idSet) {
4485
+ ctx.deadIds.add(id);
4486
+ }
4487
+ }
4091
4488
 
4092
- function getIdIntersectionCount(ctx, node1, node2) {
4093
- let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4094
- let matchCount = 0;
4095
- for (const id of sourceSet) {
4096
- // a potential match is an id in the source and potentialIdsSet, but
4097
- // that has not already been merged into the DOM
4098
- if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4099
- ++matchCount;
4489
+ function getIdIntersectionCount(ctx, node1, node2) {
4490
+ let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
4491
+ let matchCount = 0;
4492
+ for (const id of sourceSet) {
4493
+ // a potential match is an id in the source and potentialIdsSet, but
4494
+ // that has not already been merged into the DOM
4495
+ if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
4496
+ ++matchCount;
4497
+ }
4498
+ }
4499
+ return matchCount;
4100
4500
  }
4101
- }
4102
- return matchCount;
4103
- }
4104
4501
 
4105
- /**
4106
- * A bottom up algorithm that finds all elements with ids inside of the node
4107
- * argument and populates id sets for those nodes and all their parents, generating
4108
- * a set of ids contained within all nodes for the entire hierarchy in the DOM
4109
- *
4110
- * @param node {Element}
4111
- * @param {Map<Node, Set<String>>} idMap
4112
- */
4113
- function populateIdMapForNode(node, idMap) {
4114
- let nodeParent = node.parentElement;
4115
- // find all elements with an id property
4116
- let idElements = node.querySelectorAll('[id]');
4117
- for (const elt of idElements) {
4118
- let current = elt;
4119
- // walk up the parent hierarchy of that element, adding the id
4120
- // of element to the parent's id set
4121
- while (current !== nodeParent && current != null) {
4122
- let idSet = idMap.get(current);
4123
- // if the id set doesn't exist, create it and insert it in the map
4124
- if (idSet == null) {
4125
- idSet = new Set();
4126
- idMap.set(current, idSet);
4502
+ /**
4503
+ * A bottom up algorithm that finds all elements with ids inside of the node
4504
+ * argument and populates id sets for those nodes and all their parents, generating
4505
+ * a set of ids contained within all nodes for the entire hierarchy in the DOM
4506
+ *
4507
+ * @param node {Element}
4508
+ * @param {Map<Node, Set<String>>} idMap
4509
+ */
4510
+ function populateIdMapForNode(node, idMap) {
4511
+ let nodeParent = node.parentElement;
4512
+ // find all elements with an id property
4513
+ let idElements = node.querySelectorAll('[id]');
4514
+ for (const elt of idElements) {
4515
+ let current = elt;
4516
+ // walk up the parent hierarchy of that element, adding the id
4517
+ // of element to the parent's id set
4518
+ while (current !== nodeParent && current != null) {
4519
+ let idSet = idMap.get(current);
4520
+ // if the id set doesn't exist, create it and insert it in the map
4521
+ if (idSet == null) {
4522
+ idSet = new Set();
4523
+ idMap.set(current, idSet);
4524
+ }
4525
+ idSet.add(elt.id);
4526
+ current = current.parentElement;
4527
+ }
4127
4528
  }
4128
- idSet.add(elt.id);
4129
- current = current.parentElement;
4130
4529
  }
4131
- }
4132
- }
4133
4530
 
4134
- /**
4135
- * This function computes a map of nodes to all ids contained within that node (inclusive of the
4136
- * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4137
- * for a looser definition of "matching" than tradition id matching, and allows child nodes
4138
- * to contribute to a parent nodes matching.
4139
- *
4140
- * @param {Element} oldContent the old content that will be morphed
4141
- * @param {Element} newContent the new content to morph to
4142
- * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4143
- */
4144
- function createIdMap(oldContent, newContent) {
4145
- let idMap = new Map();
4146
- populateIdMapForNode(oldContent, idMap);
4147
- populateIdMapForNode(newContent, idMap);
4148
- return idMap;
4149
- }
4531
+ /**
4532
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
4533
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
4534
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
4535
+ * to contribute to a parent nodes matching.
4536
+ *
4537
+ * @param {Element} oldContent the old content that will be morphed
4538
+ * @param {Element} newContent the new content to morph to
4539
+ * @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
4540
+ */
4541
+ function createIdMap(oldContent, newContent) {
4542
+ let idMap = new Map();
4543
+ populateIdMapForNode(oldContent, idMap);
4544
+ populateIdMapForNode(newContent, idMap);
4545
+ return idMap;
4546
+ }
4150
4547
 
4151
- //=============================================================================
4152
- // This is what ends up becoming the Idiomorph export
4153
- //=============================================================================
4154
- var idiomorph = { morph };
4548
+ //=============================================================================
4549
+ // This is what ends up becoming the Idiomorph global object
4550
+ //=============================================================================
4551
+ return {
4552
+ morph,
4553
+ defaults
4554
+ }
4555
+ })();
4155
4556
 
4156
4557
  class MorphRenderer extends Renderer {
4157
4558
  async render() {
@@ -4179,7 +4580,7 @@ class MorphRenderer extends Renderer {
4179
4580
  #morphElements(currentElement, newElement, morphStyle = "outerHTML") {
4180
4581
  this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement);
4181
4582
 
4182
- idiomorph.morph(currentElement, newElement, {
4583
+ Idiomorph.morph(currentElement, newElement, {
4183
4584
  morphStyle: morphStyle,
4184
4585
  callbacks: {
4185
4586
  beforeNodeAdded: this.#shouldAddElement,
@@ -4311,8 +4712,13 @@ class PageRenderer extends Renderer {
4311
4712
  const mergedHeadElements = this.mergeProvisionalElements();
4312
4713
  const newStylesheetElements = this.copyNewHeadStylesheetElements();
4313
4714
  this.copyNewHeadScriptElements();
4715
+
4314
4716
  await mergedHeadElements;
4315
4717
  await newStylesheetElements;
4718
+
4719
+ if (this.willRender) {
4720
+ this.removeUnusedHeadStylesheetElements();
4721
+ }
4316
4722
  }
4317
4723
 
4318
4724
  async replaceBody() {
@@ -4344,6 +4750,12 @@ class PageRenderer extends Renderer {
4344
4750
  }
4345
4751
  }
4346
4752
 
4753
+ removeUnusedHeadStylesheetElements() {
4754
+ for (const element of this.unusedHeadStylesheetElements) {
4755
+ document.head.removeChild(element);
4756
+ }
4757
+ }
4758
+
4347
4759
  async mergeProvisionalElements() {
4348
4760
  const newHeadElements = [...this.newHeadProvisionalElements];
4349
4761
 
@@ -4409,6 +4821,20 @@ class PageRenderer extends Renderer {
4409
4821
  await this.renderElement(this.currentElement, this.newElement);
4410
4822
  }
4411
4823
 
4824
+ get unusedHeadStylesheetElements() {
4825
+ return this.oldHeadStylesheetElements.filter((element) => {
4826
+ return !(element.hasAttribute("data-turbo-permanent") ||
4827
+ // Trix dynamically adds styles to the head that we want to keep around which have a
4828
+ // `data-tag-name` attribute. Long term we should moves those styles to Trix's CSS file
4829
+ // but for now we'll just skip removing them
4830
+ element.hasAttribute("data-tag-name"))
4831
+ })
4832
+ }
4833
+
4834
+ get oldHeadStylesheetElements() {
4835
+ return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
4836
+ }
4837
+
4412
4838
  get newHeadStylesheetElements() {
4413
4839
  return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
4414
4840
  }
@@ -4535,7 +4961,11 @@ class PageView extends View {
4535
4961
  }
4536
4962
 
4537
4963
  isPageRefresh(visit) {
4538
- return !visit || (this.lastRenderedLocation.href === visit.location.href && visit.action === "replace")
4964
+ return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace")
4965
+ }
4966
+
4967
+ shouldPreserveScrollPosition(visit) {
4968
+ return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition
4539
4969
  }
4540
4970
 
4541
4971
  get snapshot() {
@@ -4546,27 +4976,28 @@ class PageView extends View {
4546
4976
  class Preloader {
4547
4977
  selector = "a[data-turbo-preload]"
4548
4978
 
4549
- constructor(delegate) {
4979
+ constructor(delegate, snapshotCache) {
4550
4980
  this.delegate = delegate;
4551
- }
4552
-
4553
- get snapshotCache() {
4554
- return this.delegate.navigator.view.snapshotCache
4981
+ this.snapshotCache = snapshotCache;
4555
4982
  }
4556
4983
 
4557
4984
  start() {
4558
4985
  if (document.readyState === "loading") {
4559
- return document.addEventListener("DOMContentLoaded", () => {
4560
- this.preloadOnLoadLinksForView(document.body);
4561
- })
4986
+ document.addEventListener("DOMContentLoaded", this.#preloadAll);
4562
4987
  } else {
4563
4988
  this.preloadOnLoadLinksForView(document.body);
4564
4989
  }
4565
4990
  }
4566
4991
 
4992
+ stop() {
4993
+ document.removeEventListener("DOMContentLoaded", this.#preloadAll);
4994
+ }
4995
+
4567
4996
  preloadOnLoadLinksForView(element) {
4568
4997
  for (const link of element.querySelectorAll(this.selector)) {
4569
- this.preloadURL(link);
4998
+ if (this.delegate.shouldPreloadLink(link)) {
4999
+ this.preloadURL(link);
5000
+ }
4570
5001
  }
4571
5002
  }
4572
5003
 
@@ -4577,31 +5008,39 @@ class Preloader {
4577
5008
  return
4578
5009
  }
4579
5010
 
5011
+ const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link);
5012
+ await fetchRequest.perform();
5013
+ }
5014
+
5015
+ // Fetch request delegate
5016
+
5017
+ prepareRequest(fetchRequest) {
5018
+ fetchRequest.headers["Sec-Purpose"] = "prefetch";
5019
+ }
5020
+
5021
+ async requestSucceededWithResponse(fetchRequest, fetchResponse) {
4580
5022
  try {
4581
- const response = await fetch(location.toString(), { headers: { "Sec-Purpose": "prefetch", Accept: "text/html" } });
4582
- const responseText = await response.text();
4583
- const snapshot = PageSnapshot.fromHTMLString(responseText);
5023
+ const responseHTML = await fetchResponse.responseHTML;
5024
+ const snapshot = PageSnapshot.fromHTMLString(responseHTML);
4584
5025
 
4585
- this.snapshotCache.put(location, snapshot);
5026
+ this.snapshotCache.put(fetchRequest.url, snapshot);
4586
5027
  } catch (_) {
4587
5028
  // If we cannot preload that is ok!
4588
5029
  }
4589
5030
  }
4590
- }
4591
5031
 
4592
- class LimitedSet extends Set {
4593
- constructor(maxSize) {
4594
- super();
4595
- this.maxSize = maxSize;
4596
- }
5032
+ requestStarted(fetchRequest) {}
4597
5033
 
4598
- add(value) {
4599
- if (this.size >= this.maxSize) {
4600
- const iterator = this.values();
4601
- const oldestValue = iterator.next().value;
4602
- this.delete(oldestValue);
4603
- }
4604
- super.add(value);
5034
+ requestErrored(fetchRequest) {}
5035
+
5036
+ requestFinished(fetchRequest) {}
5037
+
5038
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
5039
+
5040
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
5041
+
5042
+ #preloadAll = () => {
5043
+ this.preloadOnLoadLinksForView(document.body);
4605
5044
  }
4606
5045
  }
4607
5046
 
@@ -4634,12 +5073,12 @@ class Cache {
4634
5073
  class Session {
4635
5074
  navigator = new Navigator(this)
4636
5075
  history = new History(this)
4637
- preloader = new Preloader(this)
4638
5076
  view = new PageView(this, document.documentElement)
4639
5077
  adapter = new BrowserAdapter(this)
4640
5078
 
4641
5079
  pageObserver = new PageObserver(this)
4642
5080
  cacheObserver = new CacheObserver()
5081
+ linkPrefetchObserver = new LinkPrefetchObserver(this, document)
4643
5082
  linkClickObserver = new LinkClickObserver(this, window)
4644
5083
  formSubmitObserver = new FormSubmitObserver(this, document)
4645
5084
  scrollObserver = new ScrollObserver(this)
@@ -4648,18 +5087,26 @@ class Session {
4648
5087
  frameRedirector = new FrameRedirector(this, document.documentElement)
4649
5088
  streamMessageRenderer = new StreamMessageRenderer()
4650
5089
  cache = new Cache(this)
4651
- recentRequests = new LimitedSet(20)
4652
5090
 
4653
5091
  drive = true
4654
5092
  enabled = true
4655
5093
  progressBarDelay = 500
4656
5094
  started = false
4657
5095
  formMode = "on"
5096
+ #pageRefreshDebouncePeriod = 150
5097
+
5098
+ constructor(recentRequests) {
5099
+ this.recentRequests = recentRequests;
5100
+ this.preloader = new Preloader(this, this.view.snapshotCache);
5101
+ this.debouncedRefresh = this.refresh;
5102
+ this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
5103
+ }
4658
5104
 
4659
5105
  start() {
4660
5106
  if (!this.started) {
4661
5107
  this.pageObserver.start();
4662
5108
  this.cacheObserver.start();
5109
+ this.linkPrefetchObserver.start();
4663
5110
  this.formLinkClickObserver.start();
4664
5111
  this.linkClickObserver.start();
4665
5112
  this.formSubmitObserver.start();
@@ -4681,6 +5128,7 @@ class Session {
4681
5128
  if (this.started) {
4682
5129
  this.pageObserver.stop();
4683
5130
  this.cacheObserver.stop();
5131
+ this.linkPrefetchObserver.stop();
4684
5132
  this.formLinkClickObserver.stop();
4685
5133
  this.linkClickObserver.stop();
4686
5134
  this.formSubmitObserver.stop();
@@ -4688,6 +5136,7 @@ class Session {
4688
5136
  this.streamObserver.stop();
4689
5137
  this.frameRedirector.stop();
4690
5138
  this.history.stop();
5139
+ this.preloader.stop();
4691
5140
  this.started = false;
4692
5141
  }
4693
5142
  }
@@ -4747,13 +5196,42 @@ class Session {
4747
5196
  return this.history.restorationIdentifier
4748
5197
  }
4749
5198
 
5199
+ get pageRefreshDebouncePeriod() {
5200
+ return this.#pageRefreshDebouncePeriod
5201
+ }
5202
+
5203
+ set pageRefreshDebouncePeriod(value) {
5204
+ this.refresh = debounce(this.debouncedRefresh.bind(this), value);
5205
+ this.#pageRefreshDebouncePeriod = value;
5206
+ }
5207
+
5208
+ // Preloader delegate
5209
+
5210
+ shouldPreloadLink(element) {
5211
+ const isUnsafe = element.hasAttribute("data-turbo-method");
5212
+ const isStream = element.hasAttribute("data-turbo-stream");
5213
+ const frameTarget = element.getAttribute("data-turbo-frame");
5214
+ const frame = frameTarget == "_top" ?
5215
+ null :
5216
+ document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])");
5217
+
5218
+ if (isUnsafe || isStream || frame instanceof FrameElement) {
5219
+ return false
5220
+ } else {
5221
+ const location = new URL(element.href);
5222
+
5223
+ return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation)
5224
+ }
5225
+ }
5226
+
4750
5227
  // History delegate
4751
5228
 
4752
- historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier) {
5229
+ historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) {
4753
5230
  if (this.enabled) {
4754
5231
  this.navigator.startVisit(location, restorationIdentifier, {
4755
5232
  action: "restore",
4756
- historyChanged: true
5233
+ historyChanged: true,
5234
+ direction
4757
5235
  });
4758
5236
  } else {
4759
5237
  this.adapter.pageInvalidated({
@@ -4776,6 +5254,15 @@ class Session {
4776
5254
 
4777
5255
  submittedFormLinkToLocation() {}
4778
5256
 
5257
+ // Link hover observer delegate
5258
+
5259
+ canPrefetchRequestToLocation(link, location) {
5260
+ return (
5261
+ this.elementIsNavigatable(link) &&
5262
+ locationIsVisitable(location, this.snapshot.rootLocation)
5263
+ )
5264
+ }
5265
+
4779
5266
  // Link click observer delegate
4780
5267
 
4781
5268
  willFollowLinkToLocation(link, location, event) {
@@ -4809,6 +5296,7 @@ class Session {
4809
5296
  visitStarted(visit) {
4810
5297
  if (!visit.acceptsStreamResponse) {
4811
5298
  markAsBusy(document.documentElement);
5299
+ this.view.markVisitDirection(visit.direction);
4812
5300
  }
4813
5301
  extendURLWithDeprecatedProperties(visit.location);
4814
5302
  if (!visit.silent) {
@@ -4817,6 +5305,7 @@ class Session {
4817
5305
  }
4818
5306
 
4819
5307
  visitCompleted(visit) {
5308
+ this.view.unmarkVisitDirection();
4820
5309
  clearBusyState(document.documentElement);
4821
5310
  this.notifyApplicationAfterPageLoad(visit.getTimingMetrics());
4822
5311
  }
@@ -4873,8 +5362,8 @@ class Session {
4873
5362
  }
4874
5363
  }
4875
5364
 
4876
- allowsImmediateRender({ element }, isPreview, options) {
4877
- const event = this.notifyApplicationBeforeRender(element, isPreview, options);
5365
+ allowsImmediateRender({ element }, options) {
5366
+ const event = this.notifyApplicationBeforeRender(element, options);
4878
5367
  const {
4879
5368
  defaultPrevented,
4880
5369
  detail: { render }
@@ -4887,9 +5376,9 @@ class Session {
4887
5376
  return !defaultPrevented
4888
5377
  }
4889
5378
 
4890
- viewRenderedSnapshot(_snapshot, isPreview, renderMethod) {
5379
+ viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) {
4891
5380
  this.view.lastRenderedLocation = this.history.location;
4892
- this.notifyApplicationAfterRender(isPreview, renderMethod);
5381
+ this.notifyApplicationAfterRender(renderMethod);
4893
5382
  }
4894
5383
 
4895
5384
  preloadOnLoadLinksForView(element) {
@@ -4945,15 +5434,15 @@ class Session {
4945
5434
  return dispatch("turbo:before-cache")
4946
5435
  }
4947
5436
 
4948
- notifyApplicationBeforeRender(newBody, isPreview, options) {
5437
+ notifyApplicationBeforeRender(newBody, options) {
4949
5438
  return dispatch("turbo:before-render", {
4950
- detail: { newBody, isPreview, ...options },
5439
+ detail: { newBody, ...options },
4951
5440
  cancelable: true
4952
5441
  })
4953
5442
  }
4954
5443
 
4955
- notifyApplicationAfterRender(isPreview, renderMethod) {
4956
- return dispatch("turbo:render", { detail: { isPreview, renderMethod } })
5444
+ notifyApplicationAfterRender(renderMethod) {
5445
+ return dispatch("turbo:render", { detail: { renderMethod } })
4957
5446
  }
4958
5447
 
4959
5448
  notifyApplicationAfterPageLoad(timing = {}) {
@@ -5055,7 +5544,7 @@ const deprecatedLocationPropertyDescriptors = {
5055
5544
  }
5056
5545
  };
5057
5546
 
5058
- const session = new Session();
5547
+ const session = new Session(recentRequests);
5059
5548
  const { cache, navigator: navigator$1 } = session;
5060
5549
 
5061
5550
  /**
@@ -5165,7 +5654,7 @@ var Turbo = /*#__PURE__*/Object.freeze({
5165
5654
  PageRenderer: PageRenderer,
5166
5655
  PageSnapshot: PageSnapshot,
5167
5656
  FrameRenderer: FrameRenderer,
5168
- fetch: fetch,
5657
+ fetch: fetchWithTurboHeaders,
5169
5658
  start: start,
5170
5659
  registerAdapter: registerAdapter,
5171
5660
  visit: visit,
@@ -5413,7 +5902,7 @@ class FrameController {
5413
5902
 
5414
5903
  // View delegate
5415
5904
 
5416
- allowsImmediateRender({ element: newFrame }, _isPreview, options) {
5905
+ allowsImmediateRender({ element: newFrame }, options) {
5417
5906
  const event = dispatch("turbo:before-frame-render", {
5418
5907
  target: this.element,
5419
5908
  detail: { newFrame, ...options },
@@ -6034,7 +6523,7 @@ if (customElements.get("turbo-stream-source") === undefined) {
6034
6523
  }
6035
6524
  })();
6036
6525
 
6037
- window.Turbo = Turbo;
6526
+ window.Turbo = { ...Turbo, StreamActions };
6038
6527
  start();
6039
6528
 
6040
- export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, clearCache, connectStreamSource, disconnectStreamSource, fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, navigator$1 as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit };
6529
+ export { FetchEnctype, FetchMethod, FetchRequest, FetchResponse, FrameElement, FrameLoadingStyle, FrameRenderer, PageRenderer, PageSnapshot, StreamActions, StreamElement, StreamSourceElement, cache, clearCache, connectStreamSource, disconnectStreamSource, fetchWithTurboHeaders as fetch, fetchEnctypeFromString, fetchMethodFromString, isSafe, navigator$1 as navigator, registerAdapter, renderStreamMessage, session, setConfirmMethod, setFormMode, setProgressBarDelay, start, visit };