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 @@ + + +
+ +