diff --git a/README.md b/README.md index 154c2698d..ed74ef6da 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,4 @@ Read more on [turbo.hotwired.dev](https://turbo.hotwired.dev). Please read [CONTRIBUTING.md](./CONTRIBUTING.md). -© 2023 37signals LLC. +© 2024 37signals LLC. diff --git a/package.json b/package.json index c74caab8d..1db81757f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.0-beta.1", + "version": "8.0.0-beta.4", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", @@ -44,7 +44,7 @@ "chai": "~4.3.4", "eslint": "^8.13.0", "express": "^4.18.2", - "idiomorph": "https://github.com/basecamp/idiomorph#rollout-build", + "idiomorph": "^0.3.0", "multer": "^1.4.2", "rollup": "^2.35.1" }, diff --git a/playwright.config.js b/playwright.config.js index aae8335d2..4b4dfdc22 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -8,7 +8,8 @@ const config = { ...devices["Desktop Chrome"], contextOptions: { timeout: 60000 - } + }, + hasTouch: true } }, { @@ -17,7 +18,8 @@ const config = { ...devices["Desktop Firefox"], contextOptions: { timeout: 60000 - } + }, + hasTouch: true } } ], diff --git a/src/core/drive/form_submission.js b/src/core/drive/form_submission.js index e2fa643b2..c2ac1a0db 100644 --- a/src/core/drive/form_submission.js +++ b/src/core/drive/form_submission.js @@ -1,7 +1,8 @@ import { FetchRequest, FetchMethod, fetchMethodFromString, fetchEnctypeFromString, isSafe } from "../../http/fetch_request" import { expandURL } from "../url" -import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util" +import { clearBusyState, dispatch, getAttribute, getMetaContent, hasAttribute, markAsBusy } from "../../util" import { StreamMessage } from "../streams/stream_message" +import { prefetchCache } from "./prefetch_cache" export const FormSubmissionState = { initialized: "initialized", @@ -117,6 +118,7 @@ export class FormSubmission { this.state = FormSubmissionState.waiting this.submitter?.setAttribute("disabled", "") this.setSubmitsWith() + markAsBusy(this.formElement) dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } @@ -125,13 +127,20 @@ export class FormSubmission { } requestPreventedHandlingResponse(request, response) { + prefetchCache.clear() + this.result = { success: response.succeeded, fetchResponse: response } } requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response) - } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { + return + } + + prefetchCache.clear() + + if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error = new Error("Form responses must redirect to another location") this.delegate.formSubmissionErrored(this, error) } else { @@ -155,6 +164,7 @@ export class FormSubmission { this.state = FormSubmissionState.stopped this.submitter?.removeAttribute("disabled") this.resetSubmitterText() + clearBusyState(this.formElement) dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result } diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index 2269e9ed4..87401ee23 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -1,4 +1,4 @@ -import Idiomorph from "idiomorph" +import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" import { dispatch } from "../../util" import { Renderer } from "../renderer" @@ -33,7 +33,9 @@ export class MorphRenderer extends Renderer { callbacks: { beforeNodeAdded: this.#shouldAddElement, beforeNodeMorphed: this.#shouldMorphElement, - beforeNodeRemoved: this.#shouldRemoveElement + beforeAttributeUpdated: this.#shouldUpdateAttribute, + beforeNodeRemoved: this.#shouldRemoveElement, + afterNodeMorphed: this.#didMorphElement } }) } @@ -44,9 +46,36 @@ export class MorphRenderer extends Renderer { #shouldMorphElement = (oldNode, newNode) => { if (oldNode instanceof HTMLElement) { - return !oldNode.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode)) - } else { - return true + if (!oldNode.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode))) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target: oldNode, + detail: { + newElement: newNode + } + }) + + return !event.defaultPrevented + } else { + return false + } + } + } + + #shouldUpdateAttribute = (attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } }) + + return !event.defaultPrevented + } + + #didMorphElement = (oldNode, newNode) => { + if (newNode instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target: oldNode, + detail: { + newElement: newNode + } + }) } } diff --git a/src/core/drive/page_renderer.js b/src/core/drive/page_renderer.js index de9eb91ea..c2789639b 100644 --- a/src/core/drive/page_renderer.js +++ b/src/core/drive/page_renderer.js @@ -1,5 +1,5 @@ -import { Renderer } from "../renderer" import { activateScriptElement, waitForLoad } from "../../util" +import { Renderer } from "../renderer" export class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { @@ -73,8 +73,13 @@ export class PageRenderer extends Renderer { const mergedHeadElements = this.mergeProvisionalElements() const newStylesheetElements = this.copyNewHeadStylesheetElements() this.copyNewHeadScriptElements() + await mergedHeadElements await newStylesheetElements + + if (this.willRender) { + this.removeUnusedDynamicStylesheetElements() + } } async replaceBody() { @@ -106,6 +111,12 @@ export class PageRenderer extends Renderer { } } + removeUnusedDynamicStylesheetElements() { + for (const element of this.unusedDynamicStylesheetElements) { + document.head.removeChild(element) + } + } + async mergeProvisionalElements() { const newHeadElements = [...this.newHeadProvisionalElements] @@ -171,6 +182,16 @@ export class PageRenderer extends Renderer { await this.renderElement(this.currentElement, this.newElement) } + get unusedDynamicStylesheetElements() { + return this.oldHeadStylesheetElements.filter((element) => { + return element.getAttribute("data-turbo-track") === "dynamic" + }) + } + + get oldHeadStylesheetElements() { + return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot) + } + get newHeadStylesheetElements() { return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot) } diff --git a/src/core/drive/prefetch_cache.js b/src/core/drive/prefetch_cache.js new file mode 100644 index 000000000..ecca297b7 --- /dev/null +++ b/src/core/drive/prefetch_cache.js @@ -0,0 +1,34 @@ +const PREFETCH_DELAY = 100 + +class PrefetchCache { + #prefetchTimeout = null + #prefetched = null + + get(url) { + if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { + return this.#prefetched.request + } + } + + setLater(url, request, ttl) { + this.clear() + + this.#prefetchTimeout = setTimeout(() => { + request.perform() + this.set(url, request, ttl) + this.#prefetchTimeout = null + }, PREFETCH_DELAY) + } + + set(url, request, ttl) { + this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) } + } + + clear() { + if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout) + this.#prefetched = null + } +} + +export const cacheTtl = 10 * 1000 +export const prefetchCache = new PrefetchCache() diff --git a/src/core/drive/preloader.js b/src/core/drive/preloader.js index 49fdf144d..d23c612a6 100644 --- a/src/core/drive/preloader.js +++ b/src/core/drive/preloader.js @@ -1,5 +1,5 @@ import { PageSnapshot } from "./page_snapshot" -import { fetch } from "../../http/fetch" +import { FetchMethod, FetchRequest } from "../../http/fetch_request" export class Preloader { selector = "a[data-turbo-preload]" @@ -36,17 +36,37 @@ export class Preloader { return } + const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link) + await fetchRequest.perform() + } + + // Fetch request delegate + + prepareRequest(fetchRequest) { + fetchRequest.headers["X-Sec-Purpose"] = "prefetch" + } + + async requestSucceededWithResponse(fetchRequest, fetchResponse) { try { - const response = await fetch(location.toString(), { headers: { "x-purpose": "preview", Accept: "text/html" } }) - const responseText = await response.text() - const snapshot = PageSnapshot.fromHTMLString(responseText) + const responseHTML = await fetchResponse.responseHTML + const snapshot = PageSnapshot.fromHTMLString(responseHTML) - this.snapshotCache.put(location, snapshot) + this.snapshotCache.put(fetchRequest.url, snapshot) } catch (_) { // If we cannot preload that is ok! } } + requestStarted(fetchRequest) {} + + requestErrored(fetchRequest) {} + + requestFinished(fetchRequest) {} + + requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} + + requestFailedWithResponse(fetchRequest, fetchResponse) {} + #preloadAll = () => { this.preloadOnLoadLinksForView(document.body) } diff --git a/src/core/drive/progress_bar.js b/src/core/drive/progress_bar.js index caff5f5da..76e8e55a7 100644 --- a/src/core/drive/progress_bar.js +++ b/src/core/drive/progress_bar.js @@ -1,5 +1,7 @@ import { unindent, getMetaContent } from "../../util" +export const ProgressBarID = "turbo-progress-bar" + export class ProgressBar { static animationDuration = 300 /*ms*/ diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 5940ba761..02358d123 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -258,7 +258,7 @@ export class FrameController { // View delegate - allowsImmediateRender({ element: newFrame }, _isPreview, options) { + allowsImmediateRender({ element: newFrame }, options) { const event = dispatch("turbo:before-frame-render", { target: this.element, detail: { newFrame, ...options }, diff --git a/src/core/session.js b/src/core/session.js index 5921571f9..7bfb20ca2 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -3,6 +3,7 @@ import { CacheObserver } from "../observers/cache_observer" import { FormSubmitObserver } from "../observers/form_submit_observer" import { FrameRedirector } from "./frames/frame_redirector" import { History } from "./drive/history" +import { LinkPrefetchObserver } from "../observers/link_prefetch_observer" import { LinkClickObserver } from "../observers/link_click_observer" import { FormLinkClickObserver } from "../observers/form_link_click_observer" import { getAction, expandURL, locationIsVisitable } from "./url" @@ -12,7 +13,7 @@ import { ScrollObserver } from "../observers/scroll_observer" import { StreamMessage } from "./streams/stream_message" import { StreamMessageRenderer } from "./streams/stream_message_renderer" import { StreamObserver } from "../observers/stream_observer" -import { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markAsBusy } from "../util" +import { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markAsBusy, debounce } from "../util" import { PageView } from "./drive/page_view" import { FrameElement } from "../elements/frame_element" import { Preloader } from "./drive/preloader" @@ -26,6 +27,7 @@ export class Session { pageObserver = new PageObserver(this) cacheObserver = new CacheObserver() + linkPrefetchObserver = new LinkPrefetchObserver(this, document) linkClickObserver = new LinkClickObserver(this, window) formSubmitObserver = new FormSubmitObserver(this, document) scrollObserver = new ScrollObserver(this) @@ -40,16 +42,20 @@ export class Session { progressBarDelay = 500 started = false formMode = "on" + #pageRefreshDebouncePeriod = 150 constructor(recentRequests) { this.recentRequests = recentRequests this.preloader = new Preloader(this, this.view.snapshotCache) + this.debouncedRefresh = this.refresh + this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod } start() { if (!this.started) { this.pageObserver.start() this.cacheObserver.start() + this.linkPrefetchObserver.start() this.formLinkClickObserver.start() this.linkClickObserver.start() this.formSubmitObserver.start() @@ -71,6 +77,7 @@ export class Session { if (this.started) { this.pageObserver.stop() this.cacheObserver.stop() + this.linkPrefetchObserver.stop() this.formLinkClickObserver.stop() this.linkClickObserver.stop() this.formSubmitObserver.stop() @@ -138,6 +145,15 @@ export class Session { return this.history.restorationIdentifier } + get pageRefreshDebouncePeriod() { + return this.#pageRefreshDebouncePeriod + } + + set pageRefreshDebouncePeriod(value) { + this.refresh = debounce(this.debouncedRefresh.bind(this), value) + this.#pageRefreshDebouncePeriod = value + } + // Preloader delegate shouldPreloadLink(element) { @@ -187,6 +203,15 @@ export class Session { submittedFormLinkToLocation() {} + // Link hover observer delegate + + canPrefetchRequestToLocation(link, location) { + return ( + this.elementIsNavigatable(link) && + locationIsVisitable(location, this.snapshot.rootLocation) + ) + } + // Link click observer delegate willFollowLinkToLocation(link, location, event) { @@ -286,8 +311,8 @@ export class Session { } } - allowsImmediateRender({ element }, isPreview, options) { - const event = this.notifyApplicationBeforeRender(element, isPreview, options) + allowsImmediateRender({ element }, options) { + const event = this.notifyApplicationBeforeRender(element, options) const { defaultPrevented, detail: { render } @@ -300,9 +325,9 @@ export class Session { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, isPreview, renderMethod) { + viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location - this.notifyApplicationAfterRender(isPreview, renderMethod) + this.notifyApplicationAfterRender(renderMethod) } preloadOnLoadLinksForView(element) { @@ -358,15 +383,15 @@ export class Session { return dispatch("turbo:before-cache") } - notifyApplicationBeforeRender(newBody, isPreview, options) { + notifyApplicationBeforeRender(newBody, options) { return dispatch("turbo:before-render", { - detail: { newBody, isPreview, ...options }, + detail: { newBody, ...options }, cancelable: true }) } - notifyApplicationAfterRender(isPreview, renderMethod) { - return dispatch("turbo:render", { detail: { isPreview, renderMethod } }) + notifyApplicationAfterRender(renderMethod) { + return dispatch("turbo:render", { detail: { renderMethod } }) } notifyApplicationAfterPageLoad(timing = {}) { diff --git a/src/core/view.js b/src/core/view.js index d077e9a24..4a0c0d7fd 100644 --- a/src/core/view.js +++ b/src/core/view.js @@ -64,8 +64,8 @@ export class View { await this.prepareToRenderSnapshot(renderer) const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve)) - const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement } - const immediateRender = this.delegate.allowsImmediateRender(snapshot, isPreview, options) + const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod } + const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) diff --git a/src/http/fetch_request.js b/src/http/fetch_request.js index 3d79f24ae..f4ff5adbe 100644 --- a/src/http/fetch_request.js +++ b/src/http/fetch_request.js @@ -121,10 +121,17 @@ export class FetchRequest { async perform() { const { fetchOptions } = this this.delegate.prepareRequest(this) - await this.#allowRequestToBeIntercepted(fetchOptions) + const event = await this.#allowRequestToBeIntercepted(fetchOptions) try { this.delegate.requestStarted(this) - const response = await fetch(this.url.href, fetchOptions) + + if (event.detail.fetchRequest) { + this.response = event.detail.fetchRequest.response + } else { + this.response = fetch(this.url.href, fetchOptions) + } + + const response = await this.response return await this.receive(response) } catch (error) { if (error.name !== "AbortError") { @@ -186,6 +193,8 @@ export class FetchRequest { }) this.url = event.detail.url if (event.defaultPrevented) await requestInterception + + return event } #willDelegateErrorHandling(error) { diff --git a/src/observers/form_link_click_observer.js b/src/observers/form_link_click_observer.js index 55b94bb64..c6471fba4 100644 --- a/src/observers/form_link_click_observer.js +++ b/src/observers/form_link_click_observer.js @@ -15,6 +15,16 @@ export class FormLinkClickObserver { this.linkInterceptor.stop() } + // Link hover observer delegate + + canPrefetchRequestToLocation(link, location) { + return false + } + + prefetchAndCacheRequestToLocation(link, location) { + return + } + // Link click observer delegate willFollowLinkToLocation(link, location, originalEvent) { diff --git a/src/observers/link_click_observer.js b/src/observers/link_click_observer.js index e6bd2fcaf..24c6aa235 100644 --- a/src/observers/link_click_observer.js +++ b/src/observers/link_click_observer.js @@ -1,5 +1,4 @@ -import { expandURL } from "../core/url" -import { findClosestRecursively } from "../util" +import { doesNotTargetIFrame, findLinkFromClickTarget, getLocationForLink } from "../util" export class LinkClickObserver { started = false @@ -31,9 +30,9 @@ export class LinkClickObserver { clickBubbled = (event) => { if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { const target = (event.composedPath && event.composedPath()[0]) || event.target - const link = this.findLinkFromClickTarget(target) + const link = findLinkFromClickTarget(target) if (link && doesNotTargetIFrame(link)) { - const location = this.getLocationForLink(link) + const location = getLocationForLink(link) if (this.delegate.willFollowLinkToLocation(link, location, event)) { event.preventDefault() this.delegate.followedLinkToLocation(link, location) @@ -53,24 +52,4 @@ export class LinkClickObserver { event.shiftKey ) } - - findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") - } - - getLocationForLink(link) { - return expandURL(link.getAttribute("href") || "") - } -} - -function doesNotTargetIFrame(anchor) { - if (anchor.hasAttribute("target")) { - for (const element of document.getElementsByName(anchor.target)) { - if (element instanceof HTMLIFrameElement) return false - } - - return true - } else { - return true - } } diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js new file mode 100644 index 000000000..19a7b9ddd --- /dev/null +++ b/src/observers/link_prefetch_observer.js @@ -0,0 +1,176 @@ +import { + doesNotTargetIFrame, + getLocationForLink, + getMetaContent, + findClosestRecursively +} from "../util" + +import { FetchMethod, FetchRequest } from "../http/fetch_request" +import { prefetchCache, cacheTtl } from "../core/drive/prefetch_cache" + +export class LinkPrefetchObserver { + started = false + hoverTriggerEvent = "mouseenter" + touchTriggerEvent = "touchstart" + + constructor(delegate, eventTarget) { + this.delegate = delegate + this.eventTarget = eventTarget + } + + start() { + if (this.started) return + + if (this.eventTarget.readyState === "loading") { + this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true }) + } else { + this.#enable() + } + } + + stop() { + if (!this.started) return + + this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, { + capture: true, + passive: true + }) + this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, { + capture: true, + passive: true + }) + this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true) + this.started = false + } + + #enable = () => { + this.eventTarget.addEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, { + capture: true, + passive: true + }) + this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, { + capture: true, + passive: true + }) + this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true) + this.started = true + } + + #tryToPrefetchRequest = (event) => { + if (getMetaContent("turbo-prefetch") !== "true") return + + const target = event.target + const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])") + + if (isLink && this.#isPrefetchable(target)) { + const link = target + const location = getLocationForLink(link) + + if (this.delegate.canPrefetchRequestToLocation(link, location)) { + const fetchRequest = new FetchRequest( + this, + FetchMethod.get, + location, + new URLSearchParams(), + target + ) + + prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl) + + link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true }) + } + } + } + + #tryToUsePrefetchedRequest = (event) => { + if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") { + const cached = prefetchCache.get(event.detail.url.toString()) + + if (cached) { + // User clicked link, use cache response + event.detail.fetchRequest = cached + } + + prefetchCache.clear() + } + } + + prepareRequest(request) { + const link = request.target + + request.headers["X-Sec-Purpose"] = "prefetch" + + const turboFrame = link.closest("turbo-frame") + const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id + + if (turboFrameTarget && turboFrameTarget !== "_top") { + request.headers["Turbo-Frame"] = turboFrameTarget + } + + if (link.hasAttribute("data-turbo-stream")) { + request.acceptResponseType("text/vnd.turbo-stream.html") + } + } + + // Fetch request interface + + requestSucceededWithResponse() {} + + requestStarted(fetchRequest) {} + + requestErrored(fetchRequest) {} + + requestFinished(fetchRequest) {} + + requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} + + requestFailedWithResponse(fetchRequest, fetchResponse) {} + + get #cacheTtl() { + return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl + } + + #isPrefetchable(link) { + const href = link.getAttribute("href") + + if (!href || href === "#" || link.dataset.turbo === "false" || link.dataset.turboPrefetch === "false") { + return false + } + + if (link.origin !== document.location.origin) { + return false + } + + if (!["http:", "https:"].includes(link.protocol)) { + return false + } + + if (link.pathname + link.search === document.location.pathname + document.location.search) { + return false + } + + if (link.dataset.turboMethod && link.dataset.turboMethod !== "get") { + return false + } + + if (targetsIframe(link)) { + return false + } + + if (link.pathname + link.search === document.location.pathname + document.location.search) { + return false + } + + const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]") + + if (turboPrefetchParent && turboPrefetchParent.dataset.turboPrefetch === "false") { + return false + } + + return true + } +} + +const targetsIframe = (link) => { + return !doesNotTargetIFrame(link) +} diff --git a/src/tests/fixtures/additional_script.html b/src/tests/fixtures/additional_script.html new file mode 100644 index 000000000..0f67d936a --- /dev/null +++ b/src/tests/fixtures/additional_script.html @@ -0,0 +1,14 @@ + + + + + Additional assets + + + + + + +

Additional assets

+ + diff --git a/src/tests/fixtures/form.html b/src/tests/fixtures/form.html index 707640393..aa46bcd2b 100644 --- a/src/tests/fixtures/form.html +++ b/src/tests/fixtures/form.html @@ -15,7 +15,7 @@

Form

-
+ diff --git a/src/tests/fixtures/hot_preloading.html b/src/tests/fixtures/hot_preloading.html index 0a9d511c5..029fdda8e 100644 --- a/src/tests/fixtures/hot_preloading.html +++ b/src/tests/fixtures/hot_preloading.html @@ -5,6 +5,7 @@ Page That Links to Preloading Page + diff --git a/src/tests/fixtures/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html new file mode 100644 index 000000000..89b94f1bb --- /dev/null +++ b/src/tests/fixtures/hover_to_prefetch.html @@ -0,0 +1,53 @@ + + + + + Hover to Prefetch + + + + + + Hover to prefetch me + Hover to prefetch me + + Hover to prefetch me + + Hover to prefetch me +
+ Won't prefetch when hovering me +
+
+ Won't prefetch when hovering me +
+ Hover to prefetch me +
+
+ Won't prefetch when hovering me + Won't prefetch when hovering me + Won't prefetch when hovering me + Hover to prefetch me + Won't prefetch when hovering me + Hover to prefetch me + Won't prefetch when hovering me + Won't prefetch when hovering me + Won't prefetch when hovering me + Won't prefetch when hovering me + + + + Hover to prefetch me + + + + Hover to prefetch me + + + diff --git a/src/tests/fixtures/hover_to_prefetch_custom_cache_time.html b/src/tests/fixtures/hover_to_prefetch_custom_cache_time.html new file mode 100644 index 000000000..00ed35fd2 --- /dev/null +++ b/src/tests/fixtures/hover_to_prefetch_custom_cache_time.html @@ -0,0 +1,14 @@ + + + + + Hover to Prefetch + + + + + + + Hover to prefetch me + + diff --git a/src/tests/fixtures/hover_to_prefetch_disabled.html b/src/tests/fixtures/hover_to_prefetch_disabled.html new file mode 100644 index 000000000..689041d39 --- /dev/null +++ b/src/tests/fixtures/hover_to_prefetch_disabled.html @@ -0,0 +1,13 @@ + + + + + Hover to Prefetch + + + + + + Hover to prefetch me + + diff --git a/src/tests/fixtures/hover_to_prefetch_iframe.html b/src/tests/fixtures/hover_to_prefetch_iframe.html new file mode 100644 index 000000000..046fefbd8 --- /dev/null +++ b/src/tests/fixtures/hover_to_prefetch_iframe.html @@ -0,0 +1,13 @@ + + + + + Hover to Prefetch + + + + + + Hover to prefetch me + + diff --git a/src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html b/src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html new file mode 100644 index 000000000..75a7265aa --- /dev/null +++ b/src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html @@ -0,0 +1,19 @@ + + + + + Hover to Prefetch Not Enabled + + + + + + + Click to go to page with prefetch meta tag + + + diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html index d9523c6b1..f7f577d56 100644 --- a/src/tests/fixtures/page_refresh.html +++ b/src/tests/fixtures/page_refresh.html @@ -9,6 +9,45 @@ + + + + + + +

Left

+

go right

+ + diff --git a/src/tests/fixtures/stylesheets/right.css b/src/tests/fixtures/stylesheets/right.css new file mode 100644 index 000000000..38bdfb182 --- /dev/null +++ b/src/tests/fixtures/stylesheets/right.css @@ -0,0 +1,3 @@ +body { + background-color: rgb(0, 128, 0); +} diff --git a/src/tests/fixtures/stylesheets/right.html b/src/tests/fixtures/stylesheets/right.html new file mode 100644 index 000000000..2805568e1 --- /dev/null +++ b/src/tests/fixtures/stylesheets/right.html @@ -0,0 +1,20 @@ + + + + + Right + + + + + + + + +

Right

+

go left

+ + diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index b461c971f..7ba0f39e7 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -84,6 +84,10 @@ "turbo:frame-render", "turbo:frame-missing", "turbo:before-frame-morph", + "turbo:morph", + "turbo:before-morph-element", + "turbo:morph-element", + "turbo:before-morph-attribute", "turbo:reload" ]) diff --git a/src/tests/functional/drive_stylesheet_merging_tests.js b/src/tests/functional/drive_stylesheet_merging_tests.js new file mode 100644 index 000000000..d914a146e --- /dev/null +++ b/src/tests/functional/drive_stylesheet_merging_tests.js @@ -0,0 +1,30 @@ +import { test } from "@playwright/test" +import { assert } from "chai" +import { cssClassIsDefined, getComputedStyle, hasSelector, nextBody } from "../helpers/page" + +test.beforeEach(async ({ page }) => { + await page.goto("/src/tests/fixtures/stylesheets/left.html") +}) + +test("navigating removes unused dynamically tracked style elements", async ({ page }) => { + assert.ok(await hasSelector(page, 'style[id="added-style"]')) + assert.ok(await hasSelector(page, 'link[id="added-link"]')) + + await page.locator("#go-right").click() + await nextBody(page) + + assert.ok(await hasSelector(page, 'link[rel=stylesheet][href="/src/tests/fixtures/stylesheets/common.css"]')) + assert.ok(await hasSelector(page, 'link[rel=stylesheet][href="/src/tests/fixtures/stylesheets/right.css"]')) + assert.notOk(await hasSelector(page, 'link[rel=stylesheet][href="/src/tests/fixtures/stylesheets/left.css"]')) + assert.equal(await getComputedStyle(page, "body", "backgroundColor"), "rgb(0, 128, 0)") + assert.equal(await getComputedStyle(page, "body", "color"), "rgb(0, 128, 0)") + + assert.ok(await hasSelector(page, 'style[id="added-style"]')) + assert.ok(await hasSelector(page, 'link[id="added-link"]')) + + assert.ok(await cssClassIsDefined(page, "right")) + assert.notOk(await cssClassIsDefined(page, "left")) + assert.equal(await getComputedStyle(page, "body", "marginLeft"), "0px") + assert.equal(await getComputedStyle(page, "body", "marginRight"), "20px") +}) + diff --git a/src/tests/functional/form_submission_tests.js b/src/tests/functional/form_submission_tests.js index 987038448..e7447944f 100644 --- a/src/tests/functional/form_submission_tests.js +++ b/src/tests/functional/form_submission_tests.js @@ -131,6 +131,25 @@ test("standard POST form submission with redirect response", async ({ page }) => ) }) + +test("sets aria-busy on the form element during a form submission", async ({ page }) => { + await page.click("#standard form.redirect input[type=submit]") + + await nextEventNamed(page, "turbo:submit-start") + assert.equal( + await nextAttributeMutationNamed(page, "standard-form", "aria-busy"), + "true", + "sets [aria-busy] on the form element" + ) + + await nextEventNamed(page, "turbo:submit-end") + assert.equal( + await nextAttributeMutationNamed(page, "standard-form", "aria-busy"), + null, + "removes [aria-busy] from the form element" + ) +}) + test("standard POST form submission events", async ({ page }) => { await page.click("#standard-post-form-submit") diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js new file mode 100644 index 000000000..584d673ef --- /dev/null +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -0,0 +1,325 @@ +import { test } from "@playwright/test" +import { assert } from "chai" +import { nextBeat, sleep } from "../helpers/page" +import fs from "fs" +import path from "path" + +// eslint-disable-next-line no-undef +const fixturesDir = path.join(process.cwd(), "src", "tests", "fixtures") + +test.afterEach(() => { + fs.readdirSync(fixturesDir).forEach(file => { + if (file.startsWith("volatile_posts_database")) { + fs.unlinkSync(path.join(fixturesDir, file)) + } + }) +}) + +test("it prefetches the page", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it doesn't follow the link", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await hoverSelector({ page, selector: "#anchor_for_prefetch" }) + + assert.equal(await page.title(), "Hover to Prefetch") +}) + +test("prefetches the page when link has a whole valid url as a href", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_with_whole_url" }) +}) + +test("it prefetches the page when link has the same location but with a query string", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_for_same_location_with_query" }) +}) + +test("it doesn't prefetch the page when link is inside an element with data-turbo=false", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_false_parent" }) +}) + +test("it doesn't prefetch the page when link is inside an element with data-turbo-prefetch=false", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_prefetch_false_parent" }) +}) + +test("it does prefech the page when link is inside a container with data-turbo-prefetch=true, that is within an element with data-turbo-prefetch=false", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_with_turbo_prefetch_true_parent_within_turbo_prefetch_false_parent" }) +}) + +test("it doesn't prefetch the page when link has data-turbo-prefetch=false", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_prefetch_false" }) +}) + +test("it doesn't prefetch the page when link has data-turbo=false", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_false" }) +}) + +test("it doesn't prefetch the page when link has the same location", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_same_location" }) +}) + +test("it doesn't prefetch the page when link has a different origin", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_different_origin" }) +}) + +test("it doesn't prefetch the page when link has a hash as a href", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_hash" }) +}) + +test("it doesn't prefetch the page when link has a ftp protocol", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_ftp_protocol" }) +}) + +test("it doesn't prefetch the page when links is valid but it's inside an iframe", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_iframe_target" }) +}) + +test("it doesn't prefetch the page when link has a POST data-turbo-method", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_post_method" }) +}) + +test("it doesn't prefetch the page when turbo-prefetch meta tag is set to false", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch_disabled.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it doesn't prefetch the page when turbo-prefetch meta tag is set to true, but is later set to false", async ({ + page +}) => { + await goTo({ page, path: "/hover_to_prefetch_custom_cache_time.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) + + await page.evaluate(() => { + const meta = document.querySelector('meta[name="turbo-prefetch"]') + meta.setAttribute("content", "false") + }) + + await sleep(10) + await page.mouse.move(0, 0) + + await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it prefetches when visiting a page without the meta tag, then visiting a page with it", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html" }) + + await clickSelector({ page, selector: "#anchor_for_page_with_meta_tag" }) + + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it prefetches the page when turbo-prefetch-cache-time is set to 1", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch_custom_cache_time.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it caches the request for 1 millisecond when turbo-prefetch-cache-time is set to 1", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch_custom_cache_time.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) + + await sleep(10) + await page.mouse.move(0, 0) + + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it adds text/vnd.turbo-stream.html header to the Accept header when link has data-turbo-stream", async ({ + page +}) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_with_turbo_stream", callback: (request) => { + const headers = request.headers()["accept"].split(",").map((header) => header.trim()) + + assert.includeMembers(headers, ["text/vnd.turbo-stream.html", "text/html", "application/xhtml+xml"]) + }}) +}) + +test("it prefetches links with inner elements", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_with_inner_elements" }) +}) + +test("it prefetches links inside a turbo frame", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch_in_frame", callback: (request) => { + const turboFrameHeader = request.headers()["turbo-frame"] + assert.equal(turboFrameHeader, "frame_for_prefetch") + }}) +}) + + +test("doesn't include a turbo-frame header when the link is inside a turbo frame with a target=_top", async ({ page}) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch_in_frame_target_top", callback: (request) => { + const turboFrameHeader = request.headers()["turbo-frame"] + assert.equal(undefined, turboFrameHeader) + }}) +}) + +test("it prefetches links with a delay", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + let requestMade = false + page.on("request", async (request) => (requestMade = true)) + + await page.hover("#anchor_for_prefetch") + await sleep(75) + + assertRequestNotMade(requestMade) + + await sleep(100) + + assertRequestMade(requestMade) +}) + +test("it cancels the prefetch request if the link is no longer hovered", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + let requestMade = false + page.on("request", async (request) => (requestMade = true)) + + await page.hover("#anchor_for_prefetch") + await sleep(75) + + assertRequestNotMade(requestMade) + + await page.mouse.move(0, 0) + + await sleep(100) + + assertRequestNotMade(requestMade) +}) + +test("it resets the cache when a link is hovered", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + let requestCount = 0 + page.on("request", async () => (requestCount++)) + + await page.hover("#anchor_for_prefetch") + await sleep(200) + + assert.equal(requestCount, 1) + await page.mouse.move(0, 0) + + await page.hover("#anchor_for_prefetch") + await sleep(200) + + assert.equal(requestCount, 2) +}) + +test("it prefetches page on touchstart", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertPrefetchedOnTouchstart({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it does not make a network request when clicking on a link that has been prefetched", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await hoverSelector({ page, selector: "#anchor_for_prefetch" }) + + await sleep(100) + + await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it follows the link using the cached response when clicking on a link that has been prefetched", async ({ + page +}) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await hoverSelector({ page, selector: "#anchor_for_prefetch" }) + + await clickSelector({ page, selector: "#anchor_for_prefetch" }) + assert.equal(await page.title(), "Prefetched Page") +}) + +const assertPrefetchedOnTouchstart = async ({ page, selector, callback }) => { + let requestMade = false + + page.on("request", (request) => { + callback && callback(request) + requestMade = true + }) + + const selectorXY = await page.$eval(selector, (el) => { + const { x, y } = el.getBoundingClientRect() + return { x, y } + }) + + await page.touchscreen.tap(selectorXY.x, selectorXY.y) + + await sleep(100) + + assertRequestMade(requestMade) +} + +const assertPrefetchedOnHover = async ({ page, selector, callback }) => { + let requestMade = false + + page.on("request", (request) => { + requestMade = request + }) + + await hoverSelector({ page, selector }) + + await sleep(100) + + if (callback) { + await callback(requestMade) + } + + assertRequestMade(!!requestMade) +} + +const assertNotPrefetchedOnHover = async ({ page, selector, callback }) => { + let requestMade = false + + page.on("request", (request) => { + callback && callback(request) + requestMade = true + }) + + await hoverSelector({ page, selector }) + + await sleep(100) + + assert.equal(requestMade, false, "Network request was made when it should not have been.") +} + +const assertRequestMade = (requestMade) => { + assert.equal(requestMade, true, "Network request wasn't made when it should have been.") +} + +const assertRequestNotMade = (requestMade) => { + assert.equal(requestMade, false, "Network request was made when it should not have been.") +} + +const goTo = async ({ page, path }) => { + await page.goto(`/src/tests/fixtures${path}`) + await nextBeat() +} + +const hoverSelector = async ({ page, selector }) => { + await page.hover(selector) + await nextBeat() +} + +const clickSelector = async ({ page, selector }) => { + await page.click(selector) + await nextBeat() +} diff --git a/src/tests/functional/page_refresh_stream_action_tests.js b/src/tests/functional/page_refresh_stream_action_tests.js index cbdeb272a..0bc865922 100644 --- a/src/tests/functional/page_refresh_stream_action_tests.js +++ b/src/tests/functional/page_refresh_stream_action_tests.js @@ -1,6 +1,6 @@ import { test } from "@playwright/test" import { assert } from "chai" -import { nextBeat } from "../helpers/page" +import { nextPageRefresh, readEventLogs } from "../helpers/page" test.beforeEach(async ({ page }) => { await page.goto("/src/tests/fixtures/page_refresh_stream_action.html") @@ -9,11 +9,11 @@ test.beforeEach(async ({ page }) => { test("test refreshing the page", async ({ page }) => { assert.match(await textContent(page), /Hello/) - await page.locator("#content").evaluate((content)=>content.innerHTML = "") + await page.locator("#content").evaluate((content) => content.innerHTML = "") assert.notMatch(await textContent(page), /Hello/) await page.click("#refresh button") - await nextBeat() + await nextPageRefresh(page) assert.match(await textContent(page), /Hello/) }) @@ -21,12 +21,12 @@ test("test refreshing the page", async ({ page }) => { test("don't refresh the page on self-originated request ids", async ({ page }) => { assert.match(await textContent(page), /Hello/) - await page.locator("#content").evaluate((content)=>content.innerHTML = "") - page.evaluate(()=> { window.Turbo.session.recentRequests.add("123") }) + await page.locator("#content").evaluate((content) => content.innerHTML = "") + page.evaluate(() => { window.Turbo.session.recentRequests.add("123") }) - await page.locator("#request-id").evaluate((input)=>input.value = "123") + await page.locator("#request-id").evaluate((input) => input.value = "123") await page.click("#refresh button") - await nextBeat() + await nextPageRefresh(page) assert.notMatch(await textContent(page), /Hello/) }) @@ -42,6 +42,18 @@ test("fetch injects a Turbo-Request-Id with a UID generated automatically", asyn } }) +test("debounce stream page refreshes", async ({ page }) => { + await page.click("#refresh button") + await page.click("#refresh button") + await nextPageRefresh(page) + await page.click("#refresh button") + await nextPageRefresh(page) + + const eventLogs = await readEventLogs(page) + const requestLogs = eventLogs.filter(([name]) => name == "turbo:visit") + assert.equal(requestLogs.length, 2) +}) + async function textContent(page) { const messages = await page.locator("#content") return await messages.textContent() diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js index 45bb823d6..9db411665 100644 --- a/src/tests/functional/page_refresh_tests.js +++ b/src/tests/functional/page_refresh_tests.js @@ -14,10 +14,67 @@ test("renders a page refresh with morphing", async ({ page }) => { await page.goto("/src/tests/fixtures/page_refresh.html") await page.click("#form-submit") + await nextEventNamed(page, "turbo:before-render", { renderMethod: "morph" }) await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) }) -test("renders a page refresh with morphing when the paths are the same but search params are diferent", async ({ page }) => { +test("dispatches a turbo:before-morph-element and turbo:morph-element event for each morphed element", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + await page.fill("#form-text", "Morph me") + await page.click("#form-submit") + + await nextEventOnTarget(page, "form-text", "turbo:before-morph-element") + await nextEventOnTarget(page, "form-text", "turbo:morph-element") +}) + +test("preventing a turbo:before-morph-element prevents the morph", async ({ page }) => { + const input = await page.locator("#form-text") + const submit = await page.locator("#form-submit") + + await page.goto("/src/tests/fixtures/page_refresh.html") + await input.evaluate((input) => input.addEventListener("turbo:before-morph-element", (event) => event.preventDefault())) + await input.fill("Morph me") + await submit.click() + + await nextEventOnTarget(page, "form-text", "turbo:before-morph-element") + await noNextEventOnTarget(page, "form-text", "turbo:morph-element") + + await expect(input).toHaveValue("Morph me") +}) + +test("turbo:morph-element Stimulus listeners can handle morphing", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await expect(page.locator("#test-output")).toHaveText("connected") + + await page.fill("#form-text", "Ignore me") + await page.click("#form-submit") + + await expect(page.locator("#form-text")).toHaveValue("") + await expect(page.locator("#test-output")).toHaveText("connected") +}) + +test("turbo:before-morph-attribute Stimulus listeners can handle morphing attributes", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + const controller = page.locator("#stimulus-controller") + const input = controller.locator("input") + + await expect(page.locator("#test-output")).toHaveText("connected") + + await input.fill("controller state") + await page.fill("#form-text", "Ignore me") + await page.click("#form-submit") + + const { mutationType } = await nextEventOnTarget(page, "stimulus-controller", "turbo:before-morph-attribute", { attributeName: "data-test-state-value" }) + + await expect(mutationType).toEqual("update") + await expect(controller).toHaveAttribute("data-test-state-value", "controller state") + await expect(page.locator("#form-text")).toHaveValue("") + await expect(page.locator("#test-output")).toHaveText("connected") +}) + + +test("renders a page refresh with morphing when the paths are the same but search params are different", async ({ page }) => { await page.goto("/src/tests/fixtures/page_refresh.html") await page.click("#replace-link") diff --git a/src/tests/functional/preloader_tests.js b/src/tests/functional/preloader_tests.js index d4c750b93..853e00b5c 100644 --- a/src/tests/functional/preloader_tests.js +++ b/src/tests/functional/preloader_tests.js @@ -12,6 +12,20 @@ test("preloads snapshot on initial load", async ({ page }) => { assert.ok(await urlInSnapshotCache(page, href)) }) +test("preloading dispatch turbo:before-fetch-{request,response} events", async ({ page }) => { + await page.goto("/src/tests/fixtures/preloading.html") + + const link = await page.locator("#preload_anchor") + const href = await link.evaluate((link) => link.href) + + const { url, fetchOptions } = await nextEventOnTarget(page, "preload_anchor", "turbo:before-fetch-request") + const { fetchResponse } = await nextEventOnTarget(page, "preload_anchor", "turbo:before-fetch-response") + + assert.equal(href, url, "dispatches request during preloading") + assert.equal(fetchOptions.headers.Accept, "text/html, application/xhtml+xml") + assert.equal(fetchResponse.response.url, href) +}) + test("preloads snapshot on page visit", async ({ page }) => { // contains `a[rel="preload"][href="http://localhost:9000/src/tests/fixtures/preloading.html"]` await page.goto("/src/tests/fixtures/hot_preloading.html") diff --git a/src/tests/functional/rendering_tests.js b/src/tests/functional/rendering_tests.js index d56f75e9a..66a9d1af0 100644 --- a/src/tests/functional/rendering_tests.js +++ b/src/tests/functional/rendering_tests.js @@ -28,7 +28,7 @@ test.beforeEach(async ({ page }) => { test("triggers before-render and render events", async ({ page }) => { await page.click("#same-origin-link") - const { newBody } = await nextEventNamed(page, "turbo:before-render") + const { newBody } = await nextEventNamed(page, "turbo:before-render", { renderMethod: "replace" }) assert.equal(await page.textContent("h1"), "One") @@ -36,16 +36,6 @@ test("triggers before-render and render events", async ({ page }) => { assert.equal(await newBody, await page.evaluate(() => document.body.outerHTML)) }) -test("includes isPreview in render event details", async ({ page }) => { - await page.click("#same-origin-link") - - const { isPreview } = await nextEventNamed(page, "turbo:before-render") - assert.equal(isPreview, false) - - await nextEventNamed(page, "turbo:render") - assert.equal(await isPreview, false) -}) - test("triggers before-render, render, and load events for error pages", async ({ page }) => { await page.click("#nonexistent-link") const { newBody } = await nextEventNamed(page, "turbo:before-render") @@ -222,11 +212,11 @@ test("changes the html[lang] attribute", async ({ page }) => { assert.equal(await page.getAttribute("html", "lang"), "es") }) -test("accumulates asset elements in head", async ({ page }) => { - const assetElements = () => page.$$('script, style, link[rel="stylesheet"]') +test("accumulates script elements in head", async ({ page }) => { + const assetElements = () => page.$$('script') const originalElements = await assetElements() - await page.click("#additional-assets-link") + await page.click("#additional-script-link") await nextBody(page) const newElements = await assetElements() assert.notOk(await deepElementsEqual(page, newElements, originalElements)) diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js index a7a96ac52..760b6631f 100644 --- a/src/tests/helpers/page.js +++ b/src/tests/helpers/page.js @@ -23,6 +23,28 @@ export function disposeAll(...handles) { return Promise.all(handles.map((handle) => handle.dispose())) } +export function getComputedStyle(page, selector, propertyName) { + return page.evaluate( + ([selector, propertyName]) => { + const element = document.querySelector(selector) + return getComputedStyle(element)[propertyName] + }, + [selector, propertyName] + ) +} + +export function cssClassIsDefined(page, className) { + return page.evaluate((className) => { + for (const stylesheet of document.styleSheets) { + for (const rule of stylesheet.cssRules) { + if (rule instanceof CSSStyleRule && rule.selectorText == `.${className}`) { + return true + } + } + } + }, className) +} + export function getFromLocalStorage(page, key) { return page.evaluate((storageKey) => localStorage.getItem(storageKey), key) } @@ -68,6 +90,12 @@ export function nextBody(_page, timeout = 500) { return sleep(timeout) } +export async function nextPageRefresh(page, timeout = 500) { + const pageRefreshDebouncePeriod = await page.evaluate(() => window.Turbo.session.pageRefreshDebouncePeriod) + + return sleep(pageRefreshDebouncePeriod + timeout) +} + export async function nextEventNamed(page, eventName, expectedDetail = {}) { let record while (!record) { diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 8ca8c48f3..21a9ca8aa 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -2,7 +2,7 @@ import { StreamElement } from "../../elements" import { nextAnimationFrame } from "../../util" import { DOMTestCase } from "../helpers/dom_test_case" import { assert } from "@open-wc/testing" -import { nextBeat } from "../helpers/page" +import { sleep } from "../helpers/page" import * as Turbo from "../../index" function createStreamElement(action, target, templateElement) { @@ -177,7 +177,7 @@ test("test action=refresh", async () => { const element = createStreamElement("refresh") subject.append(element) - await nextBeat() + await sleep(250) assert.notOk(document.body.hasAttribute("data-modified")) }) @@ -192,7 +192,7 @@ test("test action=refresh discarded when matching request id", async () => { element.setAttribute("request-id", "123") subject.append(element) - await nextBeat() + await sleep(250) assert.ok(document.body.hasAttribute("data-modified")) }) diff --git a/src/util.js b/src/util.js index 1dcf943fb..dcb15c31b 100644 --- a/src/util.js +++ b/src/util.js @@ -1,3 +1,5 @@ +import { expandURL } from "./core/url" + export function activateScriptElement(element) { if (element.getAttribute("data-turbo-eval") == "false") { return element @@ -215,3 +217,31 @@ export async function around(callback, reader) { return [before, after] } + +export function doesNotTargetIFrame(anchor) { + if (anchor.hasAttribute("target")) { + for (const element of document.getElementsByName(anchor.target)) { + if (element instanceof HTMLIFrameElement) return false + } + } + + return true +} + +export function findLinkFromClickTarget(target) { + return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") +} + +export function getLocationForLink(link) { + return expandURL(link.getAttribute("href") || "") +} + +export function debounce(fn, delay) { + let timeoutId = null + + return (...args) => { + const callback = () => fn.apply(this, args) + clearTimeout(timeoutId) + timeoutId = setTimeout(callback, delay) + } +} diff --git a/yarn.lock b/yarn.lock index b0e316fa0..45b1ad28f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1865,9 +1865,10 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -"idiomorph@https://github.com/basecamp/idiomorph#rollout-build": - version "0.0.8" - resolved "https://github.com/basecamp/idiomorph#e906820368e4c9c52489a3336b8c3826b1bf6de5" +idiomorph@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/idiomorph/-/idiomorph-0.3.0.tgz#f6675bc5bef1a72c94021e43141a3f605d2d6288" + integrity sha512-UhV1Ey5xCxIwR9B+OgIjQa+1Jx99XQ1vQHUsKBU1RpQzCx1u+b+N6SOXgf5mEJDqemUI/ffccu6+71l2mJUsRA== ieee754@^1.1.13: version "1.2.1"