diff --git a/_vendor/github.com/bep/turbo/v7/package.json b/_vendor/github.com/bep/turbo/v7/package.json index 2e517dbd524..8ed7e21a366 100644 --- a/_vendor/github.com/bep/turbo/v7/package.json +++ b/_vendor/github.com/bep/turbo/v7/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "7.0.1", + "version": "7.3.0", "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", @@ -20,7 +20,7 @@ "browser", "pushstate" ], - "author": "Basecamp, LLC", + "author": "37signals LLC", "contributors": [ "Jeffrey Hardy ", "Javan Makhmali ", @@ -35,15 +35,29 @@ "access": "public" }, "devDependencies": { - "@rollup/plugin-node-resolve": "9.0.0", - "@rollup/plugin-typescript": "^6.0.0", + "@open-wc/testing": "^3.1.7", + "@playwright/test": "^1.28.0", + "@rollup/plugin-node-resolve": "13.1.3", + "@rollup/plugin-typescript": "^11.0.0", "@types/multer": "^1.4.5", - "intern": "^4.9.0", - "arg": "^4.1.0", + "@typescript-eslint/eslint-plugin": "^5.50.0", + "@typescript-eslint/parser": "^5.50.0", + "@web/dev-server-esbuild": "^0.3.3", + "@web/test-runner": "^0.15.0", + "@web/test-runner-playwright": "^0.9.0", + "arg": "^5.0.1", + "body-parser": "^1.20.1", + "chai": "~4.3.4", + "eslint": "^8.13.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-prettier": "^4.0.0", + "express": "^4.18.2", "multer": "^1.4.2", + "prettier": "2.6.2", "rollup": "^2.35.1", - "tslib": "^2.0.3", - "typescript": "^4.1.3" + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "typescript": "^4.9.5" }, "scripts": { "clean": "rm -fr dist", @@ -51,10 +65,15 @@ "build": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types && rollup -c", "build:win": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types & rollup -c", "watch": "rollup -wc", - "start": "node src/tests/runner.js serveOnly", - "test": "NODE_OPTIONS=--inspect node src/tests/runner.js", - "test:win": "SET NODE_OPTIONS=--inspect & node src/tests/runner.js", - "prerelease": "yarn build && git --no-pager diff && echo && npm pack --dry-run && echo && read -n 1 -p \"Look OK? Press any key to publish and commit v$npm_package_version\" && echo", - "release": "npm publish && git commit -am \"$npm_package_name v$npm_package_version\" && git push" + "start": "ts-node -O '{\"module\":\"commonjs\"}' src/tests/server.ts", + "test": "yarn test:unit && yarn test:browser", + "test:browser": "playwright test", + "test:unit": "NODE_OPTIONS=--inspect web-test-runner", + "test:unit:win": "SET NODE_OPTIONS=--inspect & web-test-runner", + "release": "yarn build && npm publish", + "lint": "eslint . --ext .ts" + }, + "engines": { + "node": ">= 14" } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/bardo.ts b/_vendor/github.com/bep/turbo/v7/src/core/bardo.ts index 68021e3e2fc..bd6b191f0c3 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/bardo.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/bardo.ts @@ -1,31 +1,44 @@ import { PermanentElementMap } from "./snapshot" +export interface BardoDelegate { + enteringBardo(currentPermanentElement: Element, newPermanentElement: Element): void + leavingBardo(currentPermanentElement: Element): void +} + export class Bardo { readonly permanentElementMap: PermanentElementMap + readonly delegate: BardoDelegate - static preservingPermanentElements(permanentElementMap: PermanentElementMap, callback: () => void) { - const bardo = new this(permanentElementMap) + static async preservingPermanentElements( + delegate: BardoDelegate, + permanentElementMap: PermanentElementMap, + callback: () => void + ) { + const bardo = new this(delegate, permanentElementMap) bardo.enter() - callback() + await callback() bardo.leave() } - constructor(permanentElementMap: PermanentElementMap) { + constructor(delegate: BardoDelegate, permanentElementMap: PermanentElementMap) { + this.delegate = delegate this.permanentElementMap = permanentElementMap } enter() { for (const id in this.permanentElementMap) { - const [, newPermanentElement ] = this.permanentElementMap[id] + const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id] + this.delegate.enteringBardo(currentPermanentElement, newPermanentElement) this.replaceNewPermanentElementWithPlaceholder(newPermanentElement) } } leave() { for (const id in this.permanentElementMap) { - const [ currentPermanentElement ] = this.permanentElementMap[id] + const [currentPermanentElement] = this.permanentElementMap[id] this.replaceCurrentPermanentElementWithClone(currentPermanentElement) this.replacePlaceholderWithPermanentElement(currentPermanentElement) + this.delegate.leavingBardo(currentPermanentElement) } } @@ -45,11 +58,11 @@ export class Bardo { } getPlaceholderById(id: string) { - return this.placeholders.find(element => element.content == id) + return this.placeholders.find((element) => element.content == id) } get placeholders(): HTMLMetaElement[] { - return [ ...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]") ] as any + return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/cache.ts b/_vendor/github.com/bep/turbo/v7/src/core/cache.ts new file mode 100644 index 00000000000..715b7d098cc --- /dev/null +++ b/_vendor/github.com/bep/turbo/v7/src/core/cache.ts @@ -0,0 +1,30 @@ +import { Session } from "./session" +import { setMetaContent } from "../util" + +export class Cache { + readonly session: Session + + constructor(session: Session) { + this.session = session + } + + clear() { + this.session.clearCache() + } + + resetCacheControl() { + this.setCacheControl("") + } + + exemptPageFromCache() { + this.setCacheControl("no-cache") + } + + exemptPageFromPreview() { + this.setCacheControl("no-preview") + } + + private setCacheControl(value: string) { + setMetaContent("turbo-cache-control", value) + } +} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/error_renderer.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/error_renderer.ts index 838dc75abbf..22deb43e75c 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/error_renderer.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/error_renderer.ts @@ -1,23 +1,30 @@ import { PageSnapshot } from "./page_snapshot" import { Renderer } from "../renderer" +import { activateScriptElement } from "../../util" export class ErrorRenderer extends Renderer { + static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { + const { documentElement, body } = document + + documentElement.replaceChild(newElement, body) + } + async render() { this.replaceHeadAndBody() this.activateScriptElements() } replaceHeadAndBody() { - const { documentElement, head, body } = document + const { documentElement, head } = document documentElement.replaceChild(this.newHead, head) - documentElement.replaceChild(this.newElement, body) + this.renderElement(this.currentElement, this.newElement) } activateScriptElements() { for (const replaceableElement of this.scriptElements) { const parentNode = replaceableElement.parentNode if (parentNode) { - const element = this.createScriptElement(replaceableElement) + const element = activateScriptElement(replaceableElement) parentNode.replaceChild(element, replaceableElement) } } @@ -28,6 +35,6 @@ export class ErrorRenderer extends Renderer { } get scriptElements() { - return [ ...document.documentElement.querySelectorAll("script") ] + return document.documentElement.querySelectorAll("script") } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/form_submission.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/form_submission.ts index 56f57d3859c..efb75bc01a6 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/form_submission.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/form_submission.ts @@ -1,7 +1,7 @@ -import { FetchRequest, FetchMethod, fetchMethodFromString, FetchRequestHeaders } from "../../http/fetch_request" +import { FetchRequest, FetchMethod, fetchMethodFromString } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { expandURL } from "../url" -import { dispatch } from "../../util" +import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util" import { StreamMessage } from "../streams/stream_message" export interface FormSubmissionDelegate { @@ -12,9 +12,7 @@ export interface FormSubmissionDelegate { formSubmissionFinished(formSubmission: FormSubmission): void } -export type FormSubmissionResult - = { success: boolean, fetchResponse: FetchResponse } - | { success: false, error: Error } +export type FormSubmissionResult = { success: boolean; fetchResponse: FetchResponse } | { success: false; error: Error } export enum FormSubmissionState { initialized, @@ -27,15 +25,23 @@ export enum FormSubmissionState { enum FormEnctype { urlEncoded = "application/x-www-form-urlencoded", - multipart = "multipart/form-data", - plain = "text/plain" + multipart = "multipart/form-data", + plain = "text/plain", } +export type TurboSubmitStartEvent = CustomEvent<{ formSubmission: FormSubmission }> +export type TurboSubmitEndEvent = CustomEvent< + { formSubmission: FormSubmission } & { [K in keyof FormSubmissionResult]?: FormSubmissionResult[K] } +> + function formEnctypeFromString(encoding: string): FormEnctype { - switch(encoding.toLowerCase()) { - case FormEnctype.multipart: return FormEnctype.multipart - case FormEnctype.plain: return FormEnctype.plain - default: return FormEnctype.urlEncoded + switch (encoding.toLowerCase()) { + case FormEnctype.multipart: + return FormEnctype.multipart + case FormEnctype.plain: + return FormEnctype.plain + default: + return FormEnctype.urlEncoded } } @@ -44,16 +50,35 @@ export class FormSubmission { readonly formElement: HTMLFormElement readonly submitter?: HTMLElement readonly formData: FormData + readonly location: URL readonly fetchRequest: FetchRequest readonly mustRedirect: boolean state = FormSubmissionState.initialized result?: FormSubmissionResult + originalSubmitText?: string + + static confirmMethod( + message: string, + _element: HTMLFormElement, + _submitter: HTMLElement | undefined + ): Promise { + return Promise.resolve(confirm(message)) + } - constructor(delegate: FormSubmissionDelegate, formElement: HTMLFormElement, submitter?: HTMLElement, mustRedirect = false) { + constructor( + delegate: FormSubmissionDelegate, + formElement: HTMLFormElement, + submitter?: HTMLElement, + mustRedirect = false + ) { this.delegate = delegate this.formElement = formElement this.submitter = submitter this.formData = buildFormData(formElement, submitter) + this.location = expandURL(this.action) + if (this.method == FetchMethod.get) { + mergeFormDataEntries(this.location, [...this.body.entries()]) + } this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement) this.mustRedirect = mustRedirect } @@ -64,12 +89,13 @@ export class FormSubmission { } get action(): string { - const formElementAction = typeof this.formElement.action === 'string' ? this.formElement.action : null - return this.submitter?.getAttribute("formaction") || this.formElement.getAttribute("action") || formElementAction || "" - } + const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null - get location(): URL { - return expandURL(this.action) + if (this.submitter?.hasAttribute("formaction")) { + return this.submitter.getAttribute("formaction") || "" + } else { + return this.formElement.getAttribute("action") || formElementAction || "" + } } get body() { @@ -84,13 +110,13 @@ export class FormSubmission { return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.formElement.enctype) } - get isIdempotent() { - return this.fetchRequest.isIdempotent + get isSafe() { + return this.fetchRequest.isSafe } get stringFormData() { - return [ ...this.formData ].reduce((entries, [ name, value ]) => { - return entries.concat(typeof value == "string" ? [[ name, value ]] : []) + return [...this.formData].reduce((entries, [name, value]) => { + return entries.concat(typeof value == "string" ? [[name, value]] : []) }, [] as [string, string][]) } @@ -98,6 +124,15 @@ export class FormSubmission { async start() { const { initialized, requesting } = FormSubmissionState + const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement) + + if (typeof confirmationMessage === "string") { + const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter) + if (!answer) { + return + } + } + if (this.state == initialized) { this.state = requesting return this.fetchRequest.perform() @@ -115,19 +150,27 @@ export class FormSubmission { // Fetch request delegate - prepareHeadersForRequest(headers: FetchRequestHeaders, request: FetchRequest) { - if (!request.isIdempotent) { + prepareRequest(request: FetchRequest) { + if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token") if (token) { - headers["X-CSRF-Token"] = token + request.headers["X-CSRF-Token"] = token } - headers["Accept"] = [ StreamMessage.contentType, headers["Accept"] ].join(", ") + } + + if (this.requestAcceptsTurboStreamResponse(request)) { + request.acceptResponseType(StreamMessage.contentType) } } - requestStarted(request: FetchRequest) { + requestStarted(_request: FetchRequest) { this.state = FormSubmissionState.waiting - dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } }) + this.submitter?.setAttribute("disabled", "") + this.setSubmitsWith() + dispatch("turbo:submit-start", { + target: this.formElement, + detail: { formSubmission: this }, + }) this.delegate.formSubmissionStarted(this) } @@ -158,14 +201,53 @@ export class FormSubmission { this.delegate.formSubmissionErrored(this, error) } - requestFinished(request: FetchRequest) { + requestFinished(_request: FetchRequest) { this.state = FormSubmissionState.stopped - dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }}) + this.submitter?.removeAttribute("disabled") + this.resetSubmitterText() + dispatch("turbo:submit-end", { + target: this.formElement, + detail: { formSubmission: this, ...this.result }, + }) this.delegate.formSubmissionFinished(this) } + // Private + + setSubmitsWith() { + if (!this.submitter || !this.submitsWith) return + + if (this.submitter.matches("button")) { + this.originalSubmitText = this.submitter.innerHTML + this.submitter.innerHTML = this.submitsWith + } else if (this.submitter.matches("input")) { + const input = this.submitter as HTMLInputElement + this.originalSubmitText = input.value + input.value = this.submitsWith + } + } + + resetSubmitterText() { + if (!this.submitter || !this.originalSubmitText) return + + if (this.submitter.matches("button")) { + this.submitter.innerHTML = this.originalSubmitText + } else if (this.submitter.matches("input")) { + const input = this.submitter as HTMLInputElement + input.value = this.originalSubmitText + } + } + requestMustRedirect(request: FetchRequest) { - return !request.isIdempotent && this.mustRedirect + return !request.isSafe && this.mustRedirect + } + + requestAcceptsTurboStreamResponse(request: FetchRequest) { + return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) + } + + get submitsWith() { + return this.submitter?.getAttribute("data-turbo-submits-with") } } @@ -174,8 +256,8 @@ function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): F const name = submitter?.getAttribute("name") const value = submitter?.getAttribute("value") - if (name && value != null && formData.get(name) != value) { - formData.append(name, value) + if (name) { + formData.append(name, value || "") } return formData @@ -192,11 +274,20 @@ function getCookieValue(cookieName: string | null) { } } -function getMetaContent(name: string) { - const element: HTMLMetaElement | null = document.querySelector(`meta[name="${name}"]`) - return element && element.content -} - function responseSucceededWithoutRedirect(response: FetchResponse) { return response.statusCode == 200 && !response.redirected } + +function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL { + const searchParams = new URLSearchParams() + + for (const [name, value] of entries) { + if (value instanceof File) continue + + searchParams.append(name, value) + } + + url.search = searchParams.toString() + + return url +} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/head_snapshot.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/head_snapshot.ts index 14fa25cfdfb..3892332c1ed 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/head_snapshot.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/head_snapshot.ts @@ -2,7 +2,11 @@ import { Snapshot } from "../snapshot" type ElementDetailMap = { [outerHTML: string]: ElementDetails } -type ElementDetails = { type?: ElementType, tracked: boolean, elements: Element[] } +type ElementDetails = { + type?: ElementType + tracked: boolean + elements: Element[] +} type ElementType = "script" | "stylesheet" @@ -11,53 +15,53 @@ export class HeadSnapshot extends Snapshot { .filter((element) => !elementIsNoscript(element)) .map((element) => elementWithoutNonce(element)) .reduce((result, element) => { - const { outerHTML } = element - const details: ElementDetails - = outerHTML in result - ? result[outerHTML] - : { - type: elementType(element), - tracked: elementIsTracked(element), - elements: [] - } - return { - ...result, - [outerHTML]: { - ...details, - elements: [ ...details.elements, element ] + const { outerHTML } = element + const details: ElementDetails = + outerHTML in result + ? result[outerHTML] + : { + type: elementType(element), + tracked: elementIsTracked(element), + elements: [], + } + return { + ...result, + [outerHTML]: { + ...details, + elements: [...details.elements, element], + }, } - } - }, {} as ElementDetailMap) + }, {} as ElementDetailMap) get trackedElementSignature(): string { return Object.keys(this.detailsByOuterHTML) - .filter(outerHTML => this.detailsByOuterHTML[outerHTML].tracked) + .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) .join("") } getScriptElementsNotInSnapshot(snapshot: HeadSnapshot) { - return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) + return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) } getStylesheetElementsNotInSnapshot(snapshot: HeadSnapshot) { - return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) + return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) } - getElementsMatchingTypeNotInSnapshot(matchedType: ElementType, snapshot: HeadSnapshot) { + getElementsMatchingTypeNotInSnapshot(matchedType: ElementType, snapshot: HeadSnapshot): T[] { return Object.keys(this.detailsByOuterHTML) - .filter(outerHTML => !(outerHTML in snapshot.detailsByOuterHTML)) - .map(outerHTML => this.detailsByOuterHTML[outerHTML]) + .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)) + .map((outerHTML) => this.detailsByOuterHTML[outerHTML]) .filter(({ type }) => type == matchedType) - .map(({ elements: [element] }) => element) + .map(({ elements: [element] }) => element) as T[] } get provisionalElements(): Element[] { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML] if (type == null && !tracked) { - return [ ...result, ...elements ] + return [...result, ...elements] } else if (elements.length > 1) { - return [ ...result, ...elements.slice(1) ] + return [...result, ...elements.slice(1)] } else { return result } @@ -66,14 +70,14 @@ export class HeadSnapshot extends Snapshot { getMetaValue(name: string): string | null { const element = this.findMetaElementByName(name) - return element - ? element.getAttribute("content") - : null + return element ? element.getAttribute("content") : null } findMetaElementByName(name: string) { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { - const { elements: [element] } = this.detailsByOuterHTML[outerHTML] + const { + elements: [element], + } = this.detailsByOuterHTML[outerHTML] return elementIsMetaElementWithName(element, name) ? element : result }, undefined as Element | undefined) } @@ -92,22 +96,22 @@ function elementIsTracked(element: Element) { } function elementIsScript(element: Element) { - const tagName = element.tagName.toLowerCase() + const tagName = element.localName return tagName == "script" } function elementIsNoscript(element: Element) { - const tagName = element.tagName.toLowerCase() + const tagName = element.localName return tagName == "noscript" } function elementIsStylesheet(element: Element) { - const tagName = element.tagName.toLowerCase() + const tagName = element.localName return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet") } function elementIsMetaElementWithName(element: Element, name: string) { - const tagName = element.tagName.toLowerCase() + const tagName = element.localName return tagName == "meta" && element.getAttribute("name") == name } @@ -115,6 +119,6 @@ function elementWithoutNonce(element: Element) { if (element.hasAttribute("nonce")) { element.setAttribute("nonce", "") } - - return element + + return element } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/history.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/history.ts index 4054292fe13..7143adb1b16 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/history.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/history.ts @@ -9,7 +9,9 @@ type HistoryMethod = (this: typeof history, state: any, title: string, url?: str export type RestorationData = { scrollPosition?: Position } -export type RestorationDataMap = { [restorationIdentifier: string]: RestorationData } +export type RestorationDataMap = { + [restorationIdentifier: string]: RestorationData +} export class History { readonly delegate: HistoryDelegate @@ -65,7 +67,10 @@ export class History { updateRestorationData(additionalData: Partial) { const { restorationIdentifier } = this const restorationData = this.restorationData[restorationIdentifier] - this.restorationData[restorationIdentifier] = { ...restorationData, ...additionalData } + this.restorationData[restorationIdentifier] = { + ...restorationData, + ...additionalData, + } } // Scroll restoration @@ -98,7 +103,7 @@ export class History { } } - onPageLoad = async (event: Event) => { + onPageLoad = async (_event: Event) => { await nextMicrotask() this.pageLoaded = true } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/navigator.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/navigator.ts index 851b8742e41..7579913f1dc 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/navigator.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/navigator.ts @@ -1,8 +1,8 @@ -import { Action, isAction } from "../types" -import { FetchMethod } from "../../http/fetch_request" +import { Action } from "../types" +import { getVisitAction } from "../../util" import { FetchResponse } from "../../http/fetch_response" import { FormSubmission } from "./form_submission" -import { expandURL, getAnchor, getRequestURL, Locatable } from "../url" +import { expandURL, getAnchor, getRequestURL, Locatable, locationIsVisitable } from "../url" import { Visit, VisitDelegate, VisitOptions } from "./visit" import { PageSnapshot } from "./page_snapshot" @@ -23,7 +23,11 @@ export class Navigator { proposeVisit(location: URL, options: Partial = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { - this.delegate.visitProposedToLocation(location, options) + if (locationIsVisitable(location, this.view.snapshot.rootLocation)) { + this.delegate.visitProposedToLocation(location, options) + } else { + window.location.href = location.toString() + } } } @@ -31,7 +35,7 @@ export class Navigator { this.stop() this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { referrer: this.location, - ...options + ...options, }) this.currentVisit.start() } @@ -40,11 +44,7 @@ export class Navigator { this.stop() this.formSubmission = new FormSubmission(this, form, submitter, true) - if (this.formSubmission.isIdempotent) { - this.proposeVisit(this.formSubmission.fetchRequest.url, { action: this.getActionForFormSubmission(this.formSubmission) }) - } else { - this.formSubmission.start() - } + this.formSubmission.start() } stop() { @@ -75,7 +75,7 @@ export class Navigator { formSubmissionStarted(formSubmission: FormSubmission) { // Not all adapters implement formSubmissionStarted - if (typeof this.adapter.formSubmissionStarted === 'function') { + if (typeof this.adapter.formSubmissionStarted === "function") { this.adapter.formSubmissionStarted(formSubmission) } } @@ -84,12 +84,18 @@ export class Navigator { if (formSubmission == this.formSubmission) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { - if (formSubmission.method != FetchMethod.get) { + const shouldCacheSnapshot = formSubmission.isSafe + if (!shouldCacheSnapshot) { this.view.clearSnapshotCache() } - const { statusCode } = fetchResponse - const visitOptions = { response: { statusCode, responseHTML } } + const { statusCode, redirected } = fetchResponse + const action = this.getActionForFormSubmission(formSubmission) + const visitOptions = { + action, + shouldCacheSnapshot, + response: { statusCode, responseHTML, redirected }, + } this.proposeVisit(fetchResponse.location, visitOptions) } } @@ -101,9 +107,9 @@ export class Navigator { if (responseHTML) { const snapshot = PageSnapshot.fromHTMLString(responseHTML) if (fetchResponse.serverError) { - await this.view.renderError(snapshot) + await this.view.renderError(snapshot, this.currentVisit) } else { - await this.view.renderPage(snapshot) + await this.view.renderPage(snapshot, false, true, this.currentVisit) } this.view.scrollToTop() this.view.clearSnapshotCache() @@ -116,7 +122,7 @@ export class Navigator { formSubmissionFinished(formSubmission: FormSubmission) { // Not all adapters implement formSubmissionFinished - if (typeof this.adapter.formSubmissionFinished === 'function') { + if (typeof this.adapter.formSubmissionFinished === "function") { this.adapter.formSubmissionFinished(formSubmission) } } @@ -134,11 +140,13 @@ export class Navigator { locationWithActionIsSamePage(location: URL, action?: Action): boolean { const anchor = getAnchor(location) const currentAnchor = getAnchor(this.view.lastRenderedLocation) - const isRestorationToTop = action === 'restore' && typeof anchor === 'undefined' + const isRestorationToTop = action === "restore" && typeof anchor === "undefined" - return action !== "replace" && + return ( + action !== "replace" && getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) && (isRestorationToTop || (anchor != null && anchor !== currentAnchor)) + ) } visitScrolledToSamePageLocation(oldURL: URL, newURL: URL) { @@ -155,9 +163,7 @@ export class Navigator { return this.history.restorationIdentifier } - getActionForFormSubmission(formSubmission: FormSubmission): Action { - const { formElement, submitter } = formSubmission - const action = submitter?.getAttribute("data-turbo-action") || formElement.getAttribute("data-turbo-action") - return isAction(action) ? action : "advance" + getActionForFormSubmission({ submitter, formElement }: FormSubmission): Action { + return getVisitAction(submitter, formElement) || "advance" } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/page_renderer.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/page_renderer.ts index 6fdb5876818..bb401414a80 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/page_renderer.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/page_renderer.ts @@ -1,17 +1,43 @@ import { Renderer } from "../renderer" import { PageSnapshot } from "./page_snapshot" +import { ReloadReason } from "../native/browser_adapter" +import { activateScriptElement, waitForLoad } from "../../util" export class PageRenderer extends Renderer { + static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { + if (document.body && newElement instanceof HTMLBodyElement) { + document.body.replaceWith(newElement) + } else { + document.documentElement.appendChild(newElement) + } + } + get shouldRender() { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } - prepareToRender() { - this.mergeHead() + get reloadReason(): ReloadReason { + if (!this.newSnapshot.isVisitable) { + return { + reason: "turbo_visit_control_is_reload", + } + } + + if (!this.trackedElementsAreIdentical) { + return { + reason: "tracked_element_mismatch", + } + } + } + + async prepareToRender() { + await this.mergeHead() } async render() { - this.replaceBody() + if (this.willRender) { + await this.replaceBody() + } } finishRendering() { @@ -33,17 +59,18 @@ export class PageRenderer extends Renderer { return this.newSnapshot.element } - mergeHead() { - this.copyNewHeadStylesheetElements() + async mergeHead() { + const mergedHeadElements = this.mergeProvisionalElements() + const newStylesheetElements = this.copyNewHeadStylesheetElements() this.copyNewHeadScriptElements() - this.removeCurrentHeadProvisionalElements() - this.copyNewHeadProvisionalElements() + await mergedHeadElements + await newStylesheetElements } - replaceBody() { - this.preservingPermanentElements(() => { + async replaceBody() { + await this.preservingPermanentElements(async () => { this.activateNewBody() - this.assignNewBody() + await this.assignNewBody() }) } @@ -51,18 +78,61 @@ export class PageRenderer extends Renderer { return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature } - copyNewHeadStylesheetElements() { + async copyNewHeadStylesheetElements() { + const loadingElements = [] + for (const element of this.newHeadStylesheetElements) { + loadingElements.push(waitForLoad(element as HTMLLinkElement)) + document.head.appendChild(element) } + + await Promise.all(loadingElements) } copyNewHeadScriptElements() { for (const element of this.newHeadScriptElements) { - document.head.appendChild(this.createScriptElement(element)) + document.head.appendChild(activateScriptElement(element)) } } + async mergeProvisionalElements() { + const newHeadElements = [...this.newHeadProvisionalElements] + + for (const element of this.currentHeadProvisionalElements) { + if (!this.isCurrentElementInElementList(element, newHeadElements)) { + document.head.removeChild(element) + } + } + + for (const element of newHeadElements) { + document.head.appendChild(element) + } + } + + isCurrentElementInElementList(element: Element, elementList: Element[]) { + for (const [index, newElement] of elementList.entries()) { + // if title element... + if (element.tagName == "TITLE") { + if (newElement.tagName != "TITLE") { + continue + } + if (element.innerHTML == newElement.innerHTML) { + elementList.splice(index, 1) + return true + } + } + + // if any other element... + if (newElement.isEqualNode(element)) { + elementList.splice(index, 1) + return true + } + } + + return false + } + removeCurrentHeadProvisionalElements() { for (const element of this.currentHeadProvisionalElements) { document.head.removeChild(element) @@ -82,17 +152,13 @@ export class PageRenderer extends Renderer { activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { - const activatedScriptElement = this.createScriptElement(inertScriptElement) + const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } - assignNewBody() { - if (document.body && this.newElement instanceof HTMLBodyElement) { - document.body.replaceWith(this.newElement) - } else { - document.documentElement.appendChild(this.newElement) - } + async assignNewBody() { + await this.renderElement(this.currentElement, this.newElement) } get newHeadStylesheetElements() { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/page_snapshot.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/page_snapshot.ts index 9b5247fad9f..8b0172e7b66 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/page_snapshot.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/page_snapshot.ts @@ -24,7 +24,22 @@ export class PageSnapshot extends Snapshot { } clone() { - return new PageSnapshot(this.element.cloneNode(true), this.headSnapshot) + const clonedElement = this.element.cloneNode(true) + + const selectElements = this.element.querySelectorAll("select") + const clonedSelectElements = clonedElement.querySelectorAll("select") + + for (const [index, source] of selectElements.entries()) { + const clone = clonedSelectElements[index] + for (const option of clone.selectedOptions) option.selected = false + for (const option of source.selectedOptions) clone.options[option.index].selected = true + } + + for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { + clonedPasswordInput.value = "" + } + + return new PageSnapshot(clonedElement, this.headSnapshot) } get headElement() { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/page_view.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/page_view.ts index f168ac03a91..80635ab3854 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/page_view.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/page_view.ts @@ -1,27 +1,37 @@ import { nextEventLoopTick } from "../../util" -import { View, ViewDelegate } from "../view" +import { View, ViewDelegate, ViewRenderOptions } from "../view" import { ErrorRenderer } from "./error_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" +import { Visit } from "./visit" -export interface PageViewDelegate extends ViewDelegate { +export type PageViewRenderOptions = ViewRenderOptions + +export interface PageViewDelegate extends ViewDelegate { viewWillCacheSnapshot(): void } type PageViewRenderer = PageRenderer | ErrorRenderer -export class PageView extends View { +export class PageView extends View { readonly snapshotCache = new SnapshotCache(10) lastRenderedLocation = new URL(location.href) - - renderPage(snapshot: PageSnapshot, isPreview = false) { - const renderer = new PageRenderer(this.snapshot, snapshot, isPreview) + forceReloaded = false + + renderPage(snapshot: PageSnapshot, isPreview = false, willRender = true, visit?: Visit) { + const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) + if (!renderer.shouldRender) { + this.forceReloaded = true + } else { + visit?.changeHistory() + } return this.render(renderer) } - renderError(snapshot: PageSnapshot) { - const renderer = new ErrorRenderer(this.snapshot, snapshot, false) + renderError(snapshot: PageSnapshot, visit?: Visit) { + visit?.changeHistory() + const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false) return this.render(renderer) } @@ -29,12 +39,14 @@ export class PageView extends View { + this.preloadOnLoadLinksForView(document.body) + }) + } else { + this.preloadOnLoadLinksForView(document.body) + } + } + + preloadOnLoadLinksForView(element: Element) { + for (const link of element.querySelectorAll(this.selector)) { + this.preloadURL(link) + } + } + + async preloadURL(link: HTMLAnchorElement) { + const location = new URL(link.href) + + if (this.snapshotCache.has(location)) { + return + } + + try { + const response = await fetch(location.toString(), { headers: { "VND.PREFETCH": "true", Accept: "text/html" } }) + const responseText = await response.text() + const snapshot = PageSnapshot.fromHTMLString(responseText) + + this.snapshotCache.put(location, snapshot) + } catch (_) { + // If we cannot preload that is ok! + } + } +} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/progress_bar.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/progress_bar.ts index 744c0e683cb..9a189be92be 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/progress_bar.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/progress_bar.ts @@ -1,7 +1,7 @@ -import { unindent } from "../../util" +import { unindent, getMetaContent } from "../../util" export class ProgressBar { - static animationDuration = 300/*ms*/ + static animationDuration = 300 /*ms*/ static get defaultCSS() { return unindent` @@ -12,7 +12,7 @@ export class ProgressBar { left: 0; height: 3px; background: #0076ff; - z-index: 9999; + z-index: 2147483647; transition: width ${ProgressBar.animationDuration}ms ease-out, opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in; @@ -102,7 +102,7 @@ export class ProgressBar { refresh() { requestAnimationFrame(() => { - this.progressElement.style.width = `${10 + (this.value * 90)}%` + this.progressElement.style.width = `${10 + this.value * 90}%` }) } @@ -110,6 +110,9 @@ export class ProgressBar { const element = document.createElement("style") element.type = "text/css" element.textContent = ProgressBar.defaultCSS + if (this.cspNonce) { + element.nonce = this.cspNonce + } return element } @@ -118,4 +121,8 @@ export class ProgressBar { element.className = "turbo-progress-bar" return element } + + get cspNonce() { + return getMetaContent("csp-nonce") + } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/drive/visit.ts b/_vendor/github.com/bep/turbo/v7/src/core/drive/visit.ts index 8c893235c47..ca6f019cfe2 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/drive/visit.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/drive/visit.ts @@ -3,10 +3,12 @@ import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetc import { FetchResponse } from "../../http/fetch_response" import { History } from "./history" import { getAnchor } from "../url" +import { Snapshot } from "../snapshot" import { PageSnapshot } from "./page_snapshot" import { Action } from "../types" -import { uuid } from "../../util" +import { getHistoryMethodForAction, uuid } from "../../util" import { PageView } from "./page_view" +import { StreamMessage } from "../streams/stream_message" export interface VisitDelegate { readonly adapter: Adapter @@ -23,7 +25,7 @@ export enum TimingMetric { visitStart = "visitStart", requestStart = "requestStart", requestEnd = "requestEnd", - visitEnd = "visitEnd" + visitEnd = "visitEnd", } export type TimingMetrics = Partial<{ [metric in TimingMetric]: any }> @@ -33,40 +35,57 @@ export enum VisitState { started = "started", canceled = "canceled", failed = "failed", - completed = "completed" + completed = "completed", } export type VisitOptions = { - action: Action, - historyChanged: boolean, - referrer?: URL, - snapshotHTML?: string, + action: Action + historyChanged: boolean + referrer?: URL + snapshot?: PageSnapshot + snapshotHTML?: string response?: VisitResponse + visitCachedSnapshot(snapshot: Snapshot): void + willRender: boolean + updateHistory: boolean + restorationIdentifier?: string + shouldCacheSnapshot: boolean + frame?: string + acceptsStreamResponse: boolean } const defaultOptions: VisitOptions = { action: "advance", - historyChanged: false + historyChanged: false, + visitCachedSnapshot: () => {}, + willRender: true, + updateHistory: true, + shouldCacheSnapshot: true, + acceptsStreamResponse: false, } export type VisitResponse = { - statusCode: number, + statusCode: number + redirected: boolean responseHTML?: string } export enum SystemStatusCode { networkFailure = 0, timeoutFailure = -1, - contentTypeMismatch = -2 + contentTypeMismatch = -2, } export class Visit implements FetchRequestDelegate { readonly delegate: VisitDelegate - readonly identifier = uuid() + readonly identifier = uuid() // Required by turbo-ios readonly restorationIdentifier: string readonly action: Action readonly referrer?: URL readonly timingMetrics: TimingMetrics = {} + readonly visitCachedSnapshot: (snapshot: Snapshot) => void + readonly willRender: boolean + readonly updateHistory: boolean followedRedirect = false frame?: number @@ -77,22 +96,52 @@ export class Visit implements FetchRequestDelegate { request?: FetchRequest response?: VisitResponse scrolled = false + shouldCacheSnapshot = true + acceptsStreamResponse = false snapshotHTML?: string snapshotCached = false state = VisitState.initialized - - constructor(delegate: VisitDelegate, location: URL, restorationIdentifier: string | undefined, options: Partial = {}) { + snapshot?: PageSnapshot + + constructor( + delegate: VisitDelegate, + location: URL, + restorationIdentifier: string | undefined, + options: Partial = {} + ) { this.delegate = delegate this.location = location this.restorationIdentifier = restorationIdentifier || uuid() - const { action, historyChanged, referrer, snapshotHTML, response } = { ...defaultOptions, ...options } + const { + action, + historyChanged, + referrer, + snapshot, + snapshotHTML, + response, + visitCachedSnapshot, + willRender, + updateHistory, + shouldCacheSnapshot, + acceptsStreamResponse, + } = { + ...defaultOptions, + ...options, + } this.action = action this.historyChanged = historyChanged this.referrer = referrer + this.snapshot = snapshot this.snapshotHTML = snapshotHTML this.response = response this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action) + this.visitCachedSnapshot = visitCachedSnapshot + this.willRender = willRender + this.updateHistory = updateHistory + this.scrolled = !willRender + this.shouldCacheSnapshot = shouldCacheSnapshot + this.acceptsStreamResponse = acceptsStreamResponse } get adapter() { @@ -138,9 +187,12 @@ export class Visit implements FetchRequestDelegate { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd) this.state = VisitState.completed - this.adapter.visitCompleted(this) - this.delegate.visitCompleted(this) this.followRedirect() + + if (!this.followedRedirect) { + this.adapter.visitCompleted(this) + this.delegate.visitCompleted(this) + } } } @@ -152,9 +204,9 @@ export class Visit implements FetchRequestDelegate { } changeHistory() { - if (!this.historyChanged) { + if (!this.historyChanged && this.updateHistory) { const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action - const method = this.getHistoryMethodForAction(actionForHistory) + const method = getHistoryMethodForAction(actionForHistory) this.history.update(method, this.location, this.restorationIdentifier) this.historyChanged = true } @@ -203,14 +255,15 @@ export class Visit implements FetchRequestDelegate { if (this.response) { const { statusCode, responseHTML } = this.response this.render(async () => { - this.cacheSnapshot() + if (this.shouldCacheSnapshot) this.cacheSnapshot() if (this.view.renderPromise) await this.view.renderPromise if (isSuccessful(statusCode) && responseHTML != null) { - await this.view.renderPage(PageSnapshot.fromHTMLString(responseHTML)) + await this.view.renderPage(PageSnapshot.fromHTMLString(responseHTML), false, this.willRender, this) + this.performScroll(true) this.adapter.visitRendered(this) this.complete() } else { - await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML)) + await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this) this.adapter.visitRendered(this) this.fail() } @@ -248,7 +301,8 @@ export class Visit implements FetchRequestDelegate { this.adapter.visitRendered(this) } else { if (this.view.renderPromise) await this.view.renderPromise - await this.view.renderPage(snapshot, isPreview) + await this.view.renderPage(snapshot, isPreview, this.willRender, this) + this.performScroll(false) this.adapter.visitRendered(this) if (!isPreview) { this.complete() @@ -259,10 +313,12 @@ export class Visit implements FetchRequestDelegate { } followRedirect() { - if (this.redirectedToLocation && !this.followedRedirect) { + if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { - action: 'replace', - response: this.response + action: "replace", + response: this.response, + shouldCacheSnapshot: false, + willRender: false, }) this.followedRedirect = true } @@ -272,6 +328,8 @@ export class Visit implements FetchRequestDelegate { if (this.isSamePage) { this.render(async () => { this.cacheSnapshot() + this.performScroll(true) + this.changeHistory() this.adapter.visitRendered(this) }) } @@ -279,35 +337,50 @@ export class Visit implements FetchRequestDelegate { // Fetch request delegate + prepareRequest(request: FetchRequest) { + if (this.acceptsStreamResponse) { + request.acceptResponseType(StreamMessage.contentType) + } + } + requestStarted() { this.startRequest() } - requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { - - } + requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) {} async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { const responseHTML = await response.responseHTML + const { redirected, statusCode } = response if (responseHTML == undefined) { - this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch }) + this.recordResponse({ + statusCode: SystemStatusCode.contentTypeMismatch, + redirected, + }) } else { this.redirectedToLocation = response.redirected ? response.location : undefined - this.recordResponse({ statusCode: response.statusCode, responseHTML }) + this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { const responseHTML = await response.responseHTML + const { redirected, statusCode } = response if (responseHTML == undefined) { - this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch }) + this.recordResponse({ + statusCode: SystemStatusCode.contentTypeMismatch, + redirected, + }) } else { - this.recordResponse({ statusCode: response.statusCode, responseHTML }) + this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } - requestErrored(request: FetchRequest, error: Error) { - this.recordResponse({ statusCode: SystemStatusCode.networkFailure }) + requestErrored(_request: FetchRequest, _error: Error) { + this.recordResponse({ + statusCode: SystemStatusCode.networkFailure, + redirected: false, + }) } requestFinished() { @@ -316,8 +389,11 @@ export class Visit implements FetchRequestDelegate { // Scrolling - performScroll() { - if (!this.scrolled) { + performScroll(force: false) { + if(force) { + this.scrolled = false; + } + if (!this.scrolled && !this.view.forceReloaded) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop() } else { @@ -361,9 +437,11 @@ export class Visit implements FetchRequestDelegate { getHistoryMethodForAction(action: Action) { switch (action) { - case "replace": return history.replaceState + case "replace": + return history.replaceState case "advance": - case "restore": return history.pushState + case "restore": + return history.pushState } } @@ -377,25 +455,24 @@ export class Visit implements FetchRequestDelegate { } else if (this.action == "restore") { return !this.hasCachedSnapshot() } else { - return true + return this.willRender } } cacheSnapshot() { if (!this.snapshotCached) { - this.view.cacheSnapshot() + this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)) this.snapshotCached = true } } async render(callback: () => Promise) { this.cancelRender() - await new Promise(resolve => { + await new Promise((resolve) => { this.frame = requestAnimationFrame(() => resolve()) }) await callback() delete this.frame - this.performScroll() } cancelRender() { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/errors.ts b/_vendor/github.com/bep/turbo/v7/src/core/errors.ts new file mode 100644 index 00000000000..1e791f87ad3 --- /dev/null +++ b/_vendor/github.com/bep/turbo/v7/src/core/errors.ts @@ -0,0 +1 @@ +export class TurboFrameMissingError extends Error {} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/frames/form_interceptor.ts b/_vendor/github.com/bep/turbo/v7/src/core/frames/form_interceptor.ts deleted file mode 100644 index b85a49d63e4..00000000000 --- a/_vendor/github.com/bep/turbo/v7/src/core/frames/form_interceptor.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface FormInterceptorDelegate { - shouldInterceptFormSubmission(element: HTMLFormElement, submitter?: HTMLElement): boolean - formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement): void -} - -export class FormInterceptor { - readonly delegate: FormInterceptorDelegate - readonly element: Element - - constructor(delegate: FormInterceptorDelegate, element: Element) { - this.delegate = delegate - this.element = element - } - - start() { - this.element.addEventListener("submit", this.submitBubbled) - } - - stop() { - this.element.removeEventListener("submit", this.submitBubbled) - } - - submitBubbled = ((event: SubmitEvent) => { - const form = event.target - if (form instanceof HTMLFormElement && form.closest("turbo-frame, html") == this.element) { - const submitter = event.submitter || undefined - if (this.delegate.shouldInterceptFormSubmission(form, submitter)) { - event.preventDefault() - event.stopImmediatePropagation() - this.delegate.formSubmissionIntercepted(form, submitter) - } - } - }) -} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_controller.ts b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_controller.ts index 6815bc349c5..cf39a3af6e0 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_controller.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_controller.ts @@ -1,49 +1,92 @@ -import { FrameElement, FrameElementDelegate, FrameLoadingStyle } from "../../elements/frame_element" -import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" +import { + FrameElement, + FrameElementDelegate, + FrameLoadingStyle, + FrameElementObservedAttribute, +} from "../../elements/frame_element" +import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer" -import { parseHTMLDocument } from "../../util" +import { + clearBusyState, + dispatch, + getAttribute, + parseHTMLDocument, + markAsBusy, + uuid, + getHistoryMethodForAction, + getVisitAction, +} from "../../util" import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" import { Snapshot } from "../snapshot" -import { ViewDelegate } from "../view" -import { expandURL, urlsAreEqual, Locatable } from "../url" -import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor" +import { ViewDelegate, ViewRenderOptions } from "../view" +import { Locatable, getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" import { FrameView } from "./frame_view" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" +import { FormLinkClickObserver, FormLinkClickObserverDelegate } from "../../observers/form_link_click_observer" import { FrameRenderer } from "./frame_renderer" import { session } from "../index" - -export class FrameController implements AppearanceObserverDelegate, FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, FrameElementDelegate, LinkInterceptorDelegate, ViewDelegate> { +import { Action } from "../types" +import { VisitOptions } from "../drive/visit" +import { TurboBeforeFrameRenderEvent } from "../session" +import { StreamMessage } from "../streams/stream_message" +import { PageSnapshot } from "../drive/page_snapshot" +import { TurboFrameMissingError } from "../errors" + +type VisitFallback = (location: Response | Locatable, options: Partial) => Promise +export type TurboFrameMissingEvent = CustomEvent<{ response: Response; visit: VisitFallback }> + +export class FrameController + implements + AppearanceObserverDelegate, + FetchRequestDelegate, + FormSubmitObserverDelegate, + FormSubmissionDelegate, + FrameElementDelegate, + FormLinkClickObserverDelegate, + LinkInterceptorDelegate, + ViewDelegate> +{ readonly element: FrameElement readonly view: FrameView - readonly appearanceObserver: AppearanceObserver + readonly appearanceObserver: AppearanceObserver + readonly formLinkClickObserver: FormLinkClickObserver readonly linkInterceptor: LinkInterceptor - readonly formInterceptor: FormInterceptor - currentURL?: string | null + readonly formSubmitObserver: FormSubmitObserver formSubmission?: FormSubmission + fetchResponseLoaded = (_fetchResponse: FetchResponse) => {} + private currentFetchRequest: FetchRequest | null = null private resolveVisitPromise = () => {} private connected = false private hasBeenLoaded = false - private settingSourceURL = false + private ignoredAttributes: Set = new Set() + private action: Action | null = null + readonly restorationIdentifier: string + private previousFrameElement?: FrameElement + private currentNavigationElement?: Element constructor(element: FrameElement) { this.element = element this.view = new FrameView(this, this.element) this.appearanceObserver = new AppearanceObserver(this, this.element) + this.formLinkClickObserver = new FormLinkClickObserver(this, this.element) this.linkInterceptor = new LinkInterceptor(this, this.element) - this.formInterceptor = new FormInterceptor(this, this.element) + this.restorationIdentifier = uuid() + this.formSubmitObserver = new FormSubmitObserver(this, this.element) } connect() { if (!this.connected) { this.connected = true - this.reloadable = false if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() + } else { + this.loadSourceURL() } + this.formLinkClickObserver.start() this.linkInterceptor.start() - this.formInterceptor.start() - this.sourceURLChanged() + this.formSubmitObserver.start() } } @@ -51,8 +94,9 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest if (this.connected) { this.connected = false this.appearanceObserver.stop() + this.formLinkClickObserver.stop() this.linkInterceptor.stop() - this.formInterceptor.stop() + this.formSubmitObserver.stop() } } @@ -63,11 +107,33 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest } sourceURLChanged() { + if (this.isIgnoringChangesTo("src")) return + + if (this.element.isConnected) { + this.complete = false + } + if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { this.loadSourceURL() } } + sourceURLReloaded() { + const { src } = this.element + this.ignoringChangesToAttribute("complete", () => { + this.element.removeAttribute("complete") + }) + this.element.src = null + this.element.src = src + return this.element.loaded + } + + completeChanged() { + if (this.isIgnoringChangesTo("complete")) return + + this.loadSourceURL() + } + loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() @@ -77,100 +143,97 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest } } - async loadSourceURL() { - if (!this.settingSourceURL && this.enabled && this.isActive && (this.reloadable || this.sourceURL != this.currentURL)) { - const previousURL = this.currentURL - this.currentURL = this.sourceURL - if (this.sourceURL) { - try { - this.element.loaded = this.visit(this.sourceURL) - this.appearanceObserver.stop() - await this.element.loaded - this.hasBeenLoaded = true - session.frameLoaded(this.element) - } catch (error) { - this.currentURL = previousURL - throw error - } - } + private async loadSourceURL() { + if (this.enabled && this.isActive && !this.complete && this.sourceURL) { + this.element.loaded = this.visit(expandURL(this.sourceURL)) + this.appearanceObserver.stop() + await this.element.loaded + this.hasBeenLoaded = true } } async loadResponse(fetchResponse: FetchResponse) { - if (fetchResponse.redirected) { + if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } try { const html = await fetchResponse.responseHTML if (html) { - const { body } = parseHTMLDocument(html) - const snapshot = new Snapshot(await this.extractForeignFrameElement(body)) - const renderer = new FrameRenderer(this.view.snapshot, snapshot, false) - if (this.view.renderPromise) await this.view.renderPromise - await this.view.render(renderer) - session.frameRendered(fetchResponse, this.element); + const document = parseHTMLDocument(html) + const pageSnapshot = PageSnapshot.fromDocument(document) + + if (pageSnapshot.isVisitable) { + await this.loadFrameResponse(fetchResponse, document) + } else { + await this.handleUnvisitableFrameResponse(fetchResponse) + } } - } catch (error) { - console.error(error) - this.view.invalidate() + } finally { + this.fetchResponseLoaded = () => {} } } // Appearance observer delegate - elementAppearedInViewport(element: Element) { + elementAppearedInViewport(element: FrameElement) { + this.proposeVisitIfNavigatedWithAction(element, element) this.loadSourceURL() } + // Form link click observer delegate + + willSubmitFormLinkToLocation(link: Element): boolean { + return this.shouldInterceptNavigation(link) + } + + submittedFormLinkToLocation(link: Element, _location: URL, form: HTMLFormElement): void { + const frame = this.findFrameElement(link) + if (frame) form.setAttribute("data-turbo-frame", frame.id) + } + // Link interceptor delegate - shouldInterceptLinkClick(element: Element, url: string) { - if (element.hasAttribute("data-turbo-method")) { - return false - } else { - return this.shouldInterceptNavigation(element) - } + shouldInterceptLinkClick(element: Element, _location: string, _event: MouseEvent) { + return this.shouldInterceptNavigation(element) } - linkClickIntercepted(element: Element, url: string) { - this.reloadable = true - this.navigateFrame(element, url) + linkClickIntercepted(element: Element, location: string) { + this.navigateFrame(element, location) } - // Form interceptor delegate + // Form submit observer delegate - shouldInterceptFormSubmission(element: HTMLFormElement, submitter?: Element) { - return this.shouldInterceptNavigation(element, submitter) + willSubmitForm(element: HTMLFormElement, submitter?: HTMLElement) { + return element.closest("turbo-frame") == this.element && this.shouldInterceptNavigation(element, submitter) } - formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) { + formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { if (this.formSubmission) { this.formSubmission.stop() } - this.reloadable = false this.formSubmission = new FormSubmission(this, element, submitter) - if (this.formSubmission.fetchRequest.isIdempotent) { - this.navigateFrame(element, this.formSubmission.fetchRequest.url.href, submitter) - } else { - const { fetchRequest } = this.formSubmission - this.prepareHeadersForRequest(fetchRequest.headers, fetchRequest) - this.formSubmission.start() - } + const { fetchRequest } = this.formSubmission + this.prepareRequest(fetchRequest) + this.formSubmission.start() } // Fetch request delegate - prepareHeadersForRequest(headers: FetchRequestHeaders, request: FetchRequest) { - headers["Turbo-Frame"] = this.id + prepareRequest(request: FetchRequest) { + request.headers["Turbo-Frame"] = this.id + + if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { + request.acceptResponseType(StreamMessage.contentType) + } } - requestStarted(request: FetchRequest) { - this.element.setAttribute("busy", "") + requestStarted(_request: FetchRequest) { + markAsBusy(this.element) } - requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { + requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) { this.resolveVisitPromise() } @@ -179,8 +242,8 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest this.resolveVisitPromise() } - requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { - console.error(response) + async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { + await this.loadResponse(response) this.resolveVisitPromise() } @@ -189,55 +252,114 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest this.resolveVisitPromise() } - requestFinished(request: FetchRequest) { - this.element.removeAttribute("busy") + requestFinished(_request: FetchRequest) { + clearBusyState(this.element) } // Form submission delegate - formSubmissionStarted(formSubmission: FormSubmission) { - const frame = this.findFrameElement(formSubmission.formElement) - frame.setAttribute("busy", "") + formSubmissionStarted({ formElement }: FormSubmission) { + markAsBusy(formElement, this.findFrameElement(formElement)) } formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) { const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter) + + frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter) frame.delegate.loadResponse(response) + + if (!formSubmission.isSafe) { + session.clearCache() + } } formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { this.element.delegate.loadResponse(fetchResponse) + session.clearCache() } formSubmissionErrored(formSubmission: FormSubmission, error: Error) { console.error(error) } - formSubmissionFinished(formSubmission: FormSubmission) { - const frame = this.findFrameElement(formSubmission.formElement) - frame.removeAttribute("busy") + formSubmissionFinished({ formElement }: FormSubmission) { + clearBusyState(formElement, this.findFrameElement(formElement)) } // View delegate - allowsImmediateRender(snapshot: Snapshot, resume: (value: any) => void) { - return true + allowsImmediateRender({ element: newFrame }: Snapshot, options: ViewRenderOptions) { + const event = dispatch("turbo:before-frame-render", { + target: this.element, + detail: { newFrame, ...options }, + cancelable: true, + }) + const { + defaultPrevented, + detail: { render }, + } = event + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render + } + + return !defaultPrevented + } + + viewRenderedSnapshot(_snapshot: Snapshot, _isPreview: boolean) {} + + preloadOnLoadLinksForView(element: Element) { + session.preloadOnLoadLinksForView(element) } - viewRenderedSnapshot(snapshot: Snapshot, isPreview: boolean) { + viewInvalidated() {} + + // Frame renderer delegate + willRenderFrame(currentElement: FrameElement, _newElement: FrameElement) { + this.previousFrameElement = currentElement.cloneNode(true) } - viewInvalidated() { + visitCachedSnapshot = ({ element }: Snapshot) => { + const frame = element.querySelector("#" + this.element.id) + + if (frame && this.previousFrameElement) { + frame.replaceChildren(...this.previousFrameElement.children) + } + + delete this.previousFrameElement } // Private - private async visit(url: Locatable) { - const request = new FetchRequest(this, FetchMethod.get, expandURL(url), undefined, this.element) + private async loadFrameResponse(fetchResponse: FetchResponse, document: Document) { + const newFrameElement = await this.extractForeignFrameElement(document.body) + + if (newFrameElement) { + const snapshot = new Snapshot(newFrameElement) + const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false) + if (this.view.renderPromise) await this.view.renderPromise + this.changeHistory() + + await this.view.render(renderer) + this.complete = true + session.frameRendered(fetchResponse, this.element) + session.frameLoaded(this.element) + this.fetchResponseLoaded(fetchResponse) + } else if (this.willHandleFrameMissingFromResponse(fetchResponse)) { + this.handleFrameMissingFromResponse(fetchResponse) + } + } + + private async visit(url: URL) { + const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) - return new Promise(resolve => { + this.currentFetchRequest?.cancel() + this.currentFetchRequest = request + + return new Promise((resolve) => { this.resolveVisitPromise = () => { this.resolveVisitPromise = () => {} + this.currentFetchRequest = null resolve() } request.perform() @@ -246,39 +368,137 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest private navigateFrame(element: Element, url: string, submitter?: HTMLElement) { const frame = this.findFrameElement(element, submitter) - frame.setAttribute("reloadable", "") - frame.src = url + + frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter) + + this.withCurrentNavigationElement(element, () => { + frame.src = url + }) + } + + proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) { + this.action = getVisitAction(submitter, element, frame) + + if (this.action) { + const pageSnapshot = PageSnapshot.fromElement(frame).clone() + const { visitCachedSnapshot } = frame.delegate + + frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => { + if (frame.src) { + const { statusCode, redirected } = fetchResponse + const responseHTML = frame.ownerDocument.documentElement.outerHTML + const response = { statusCode, redirected, responseHTML } + const options: Partial = { + response, + visitCachedSnapshot, + willRender: false, + updateHistory: false, + restorationIdentifier: this.restorationIdentifier, + snapshot: pageSnapshot, + } + + if (this.action) options.action = this.action + + session.visit(frame.src, options) + } + } + } + } + + changeHistory() { + if (this.action) { + const method = getHistoryMethodForAction(this.action) + session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier) + } + } + + private async handleUnvisitableFrameResponse(fetchResponse: FetchResponse) { + console.warn( + `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` + ) + + await this.visitResponse(fetchResponse.response) + } + + private willHandleFrameMissingFromResponse(fetchResponse: FetchResponse): boolean { + this.element.setAttribute("complete", "") + + const response = fetchResponse.response + const visit = async (url: Locatable | Response, options: Partial = {}) => { + if (url instanceof Response) { + this.visitResponse(url) + } else { + session.visit(url, options) + } + } + + const event = dispatch("turbo:frame-missing", { + target: this.element, + detail: { response, visit }, + cancelable: true, + }) + + return !event.defaultPrevented + } + + private handleFrameMissingFromResponse(fetchResponse: FetchResponse) { + this.view.missing() + this.throwFrameMissingError(fetchResponse) + } + + private throwFrameMissingError(fetchResponse: FetchResponse) { + const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.` + throw new TurboFrameMissingError(message) + } + + private async visitResponse(response: Response): Promise { + const wrapped = new FetchResponse(response) + const responseHTML = await wrapped.responseHTML + const { location, redirected, statusCode } = wrapped + + return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } private findFrameElement(element: Element, submitter?: HTMLElement) { - const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame") || this.element.getAttribute("target") + const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") return getFrameElementById(id) ?? this.element } - async extractForeignFrameElement(container: ParentNode): Promise { + async extractForeignFrameElement(container: ParentNode): Promise { let element const id = CSS.escape(this.id) try { - if (element = activateElement(container.querySelector(`turbo-frame#${id}`), this.currentURL)) { + element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL) + if (element) { return element } - if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.currentURL)) { + element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL) + if (element) { await element.loaded return await this.extractForeignFrameElement(element) } - - console.error(`Response has no matching element`) } catch (error) { console.error(error) + return new FrameElement() } - return new FrameElement() + return null } - private shouldInterceptNavigation(element: Element, submitter?: Element) { - const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame") || this.element.getAttribute("target") + private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) { + const action = getAction(form, submitter) + + return locationIsVisitable(expandURL(action), this.rootLocation) + } + + private shouldInterceptNavigation(element: Element, submitter?: HTMLElement) { + const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") + + if (element instanceof HTMLFormElement && !this.formActionIsVisitable(element, submitter)) { + return false + } if (!this.enabled || id == "_top") { return false @@ -291,11 +511,11 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest } } - if (!session.elementDriveEnabled(element)) { + if (!session.elementIsNavigatable(element)) { return false } - if (submitter && !session.elementDriveEnabled(submitter)) { + if (submitter && !session.elementIsNavigatable(submitter)) { return false } @@ -318,25 +538,10 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest } } - get reloadable() { - const frame = this.findFrameElement(this.element) - return frame.hasAttribute("reloadable") - } - - set reloadable(value: boolean) { - const frame = this.findFrameElement(this.element) - if (value) { - frame.setAttribute("reloadable", "") - } else { - frame.removeAttribute("reloadable") - } - } - set sourceURL(sourceURL: string | undefined) { - this.settingSourceURL = true - this.element.src = sourceURL ?? null - this.currentURL = this.element.src - this.settingSourceURL = false + this.ignoringChangesToAttribute("src", () => { + this.element.src = sourceURL ?? null + }) } get loadingStyle() { @@ -347,9 +552,45 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined } + get complete() { + return this.element.hasAttribute("complete") + } + + set complete(value: boolean) { + this.ignoringChangesToAttribute("complete", () => { + if (value) { + this.element.setAttribute("complete", "") + } else { + this.element.removeAttribute("complete") + } + }) + } + get isActive() { return this.element.isActive && this.connected } + + get rootLocation() { + const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) + const root = meta?.content ?? "/" + return expandURL(root) + } + + private isIgnoringChangesTo(attributeName: FrameElementObservedAttribute): boolean { + return this.ignoredAttributes.has(attributeName) + } + + private ignoringChangesToAttribute(attributeName: FrameElementObservedAttribute, callback: () => void) { + this.ignoredAttributes.add(attributeName) + callback() + this.ignoredAttributes.delete(attributeName) + } + + private withCurrentNavigationElement(element: Element, callback: () => void) { + this.currentNavigationElement = element + callback() + delete this.currentNavigationElement + } } function getFrameElementById(id: string | null) { @@ -373,6 +614,7 @@ function activateElement(element: Element | null, currentURL?: string | null) { if (element instanceof FrameElement) { element.connectedCallback() + element.disconnectedCallback() return element } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_redirector.ts b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_redirector.ts index 96fb89bcc4f..3175e9fe0b0 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_redirector.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_redirector.ts @@ -1,55 +1,77 @@ -import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor" +import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" - -export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptorDelegate { +import { expandURL, getAction, locationIsVisitable } from "../url" +import { Session } from "../session" +export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObserverDelegate { + readonly session: Session readonly element: Element readonly linkInterceptor: LinkInterceptor - readonly formInterceptor: FormInterceptor + readonly formSubmitObserver: FormSubmitObserver - constructor(element: Element) { + constructor(session: Session, element: Element) { + this.session = session this.element = element this.linkInterceptor = new LinkInterceptor(this, element) - this.formInterceptor = new FormInterceptor(this, element) + this.formSubmitObserver = new FormSubmitObserver(this, element) } start() { this.linkInterceptor.start() - this.formInterceptor.start() + this.formSubmitObserver.start() } stop() { this.linkInterceptor.stop() - this.formInterceptor.stop() + this.formSubmitObserver.stop() } - shouldInterceptLinkClick(element: Element, url: string) { + shouldInterceptLinkClick(element: Element, _location: string, _event: MouseEvent) { return this.shouldRedirect(element) } - linkClickIntercepted(element: Element, url: string) { + linkClickIntercepted(element: Element, url: string, event: MouseEvent) { const frame = this.findFrameElement(element) if (frame) { - frame.setAttribute("reloadable", "") - frame.src = url + frame.delegate.linkClickIntercepted(element, url, event) } } - shouldInterceptFormSubmission(element: HTMLFormElement, submitter?: HTMLElement) { - return this.shouldRedirect(element, submitter) + willSubmitForm(element: HTMLFormElement, submitter?: HTMLElement) { + return ( + element.closest("turbo-frame") == null && + this.shouldSubmit(element, submitter) && + this.shouldRedirect(element, submitter) + ) } - formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) { + formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { const frame = this.findFrameElement(element, submitter) if (frame) { - frame.removeAttribute("reloadable") - frame.delegate.formSubmissionIntercepted(element, submitter) + frame.delegate.formSubmitted(element, submitter) } } + private shouldSubmit(form: HTMLFormElement, submitter?: HTMLElement) { + const action = getAction(form, submitter) + const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) + const rootLocation = expandURL(meta?.content ?? "/") + + return this.shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) + } + private shouldRedirect(element: Element, submitter?: HTMLElement) { - const frame = this.findFrameElement(element, submitter) - return frame ? frame != element.closest("turbo-frame") : false + const isNavigatable = + element instanceof HTMLFormElement + ? this.session.submissionIsNavigatable(element, submitter) + : this.session.elementIsNavigatable(element) + + if (isNavigatable) { + const frame = this.findFrameElement(element, submitter) + return frame ? frame != element.closest("turbo-frame") : false + } else { + return false + } } private findFrameElement(element: Element, submitter?: HTMLElement) { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_renderer.ts b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_renderer.ts index 398d08a9912..4f9a1725310 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_renderer.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_renderer.ts @@ -1,8 +1,40 @@ import { FrameElement } from "../../elements/frame_element" -import { nextAnimationFrame } from "../../util" -import { Renderer } from "../renderer" +import { activateScriptElement, nextAnimationFrame } from "../../util" +import { Render, Renderer } from "../renderer" +import { Snapshot } from "../snapshot" + +export interface FrameRendererDelegate { + willRenderFrame(currentElement: FrameElement, newElement: FrameElement): void +} export class FrameRenderer extends Renderer { + private readonly delegate: FrameRendererDelegate + + static renderElement(currentElement: FrameElement, newElement: FrameElement) { + const destinationRange = document.createRange() + destinationRange.selectNodeContents(currentElement) + destinationRange.deleteContents() + + const frameElement = newElement + const sourceRange = frameElement.ownerDocument?.createRange() + if (sourceRange) { + sourceRange.selectNodeContents(frameElement) + currentElement.appendChild(sourceRange.extractContents()) + } + } + + constructor( + delegate: FrameRendererDelegate, + currentSnapshot: Snapshot, + newSnapshot: Snapshot, + renderElement: Render, + isPreview: boolean, + willRender = true + ) { + super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) + this.delegate = delegate + } + get shouldRender() { return true } @@ -20,25 +52,18 @@ export class FrameRenderer extends Renderer { } loadFrameElement() { - const destinationRange = document.createRange() - destinationRange.selectNodeContents(this.currentElement) - destinationRange.deleteContents() - - const frameElement = this.newElement - const sourceRange = frameElement.ownerDocument?.createRange() - if (sourceRange) { - sourceRange.selectNodeContents(frameElement) - this.currentElement.appendChild(sourceRange.extractContents()) - } + this.delegate.willRenderFrame(this.currentElement, this.newElement) + this.renderElement(this.currentElement, this.newElement) } scrollFrameIntoView() { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end") + const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto") if (element) { - element.scrollIntoView({ block }) + element.scrollIntoView({ block, behavior }) return true } } @@ -47,14 +72,14 @@ export class FrameRenderer extends Renderer { activateScriptElements() { for (const inertScriptElement of this.newScriptElements) { - const activatedScriptElement = this.createScriptElement(inertScriptElement) + const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } get newScriptElements() { return this.currentElement.querySelectorAll("script") - } + } } function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLogicalPosition): ScrollLogicalPosition { @@ -64,3 +89,11 @@ function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLog return defaultValue } } + +function readScrollBehavior(value: string | null, defaultValue: ScrollBehavior): ScrollBehavior { + if (value == "auto" || value == "smooth") { + return value + } else { + return defaultValue + } +} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_view.ts b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_view.ts index 7dc29dbef8b..b19714f35e3 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_view.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/frames/frame_view.ts @@ -1,10 +1,12 @@ import { FrameElement } from "../../elements" import { Snapshot } from "../snapshot" -import { View } from "../view" +import { View, ViewRenderOptions } from "../view" + +export type FrameViewRenderOptions = ViewRenderOptions export class FrameView extends View { - invalidate() { - this.element.innerHTML = "" + missing() { + this.element.innerHTML = `Content missing` } get snapshot() { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/frames/link_interceptor.ts b/_vendor/github.com/bep/turbo/v7/src/core/frames/link_interceptor.ts index 2673614506e..8f2e13f4038 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/frames/link_interceptor.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/frames/link_interceptor.ts @@ -1,6 +1,8 @@ +import { TurboClickEvent, TurboBeforeVisitEvent } from "../session" + export interface LinkInterceptorDelegate { - shouldInterceptLinkClick(element: Element, url: string): boolean - linkClickIntercepted(element: Element, url: string): void + shouldInterceptLinkClick(element: Element, url: string, originalEvent: MouseEvent): boolean + linkClickIntercepted(element: Element, url: string, originalEvent: MouseEvent): void } export class LinkInterceptor { @@ -33,28 +35,23 @@ export class LinkInterceptor { } } - linkClicked = ((event: CustomEvent) => { + linkClicked = ((event: TurboClickEvent) => { if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { - if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url)) { + if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { this.clickEvent.preventDefault() event.preventDefault() - this.delegate.linkClickIntercepted(event.target, event.detail.url) + this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent) } } delete this.clickEvent }) - willVisit = () => { + willVisit = ((_event: TurboBeforeVisitEvent) => { delete this.clickEvent - } + }) respondsToEventTarget(target: EventTarget | null) { - const element - = target instanceof Element - ? target - : target instanceof Node - ? target.parentElement - : null + const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null return element && element.closest("turbo-frame, html") == this.element } } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/index.ts b/_vendor/github.com/bep/turbo/v7/src/core/index.ts index d7910d360aa..7b7507c3f0e 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/index.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/index.ts @@ -1,15 +1,37 @@ import { Adapter } from "./native/adapter" -import { Session } from "./session" +import { FormMode, Session } from "./session" +import { Cache } from "./cache" import { Locatable } from "./url" import { StreamMessage } from "./streams/stream_message" import { StreamSource } from "./types" import { VisitOptions } from "./drive/visit" import { PageRenderer } from "./drive/page_renderer" import { PageSnapshot } from "./drive/page_snapshot" +import { FrameRenderer } from "./frames/frame_renderer" +import { FormSubmission } from "./drive/form_submission" -const session = new Session +const session = new Session() +const cache = new Cache(session) const { navigator } = session -export { navigator, session, PageRenderer, PageSnapshot } +export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer } +export type { + TurboBeforeCacheEvent, + TurboBeforeRenderEvent, + TurboBeforeVisitEvent, + TurboClickEvent, + TurboBeforeFrameRenderEvent, + TurboFrameLoadEvent, + TurboFrameRenderEvent, + TurboLoadEvent, + TurboRenderEvent, + TurboVisitEvent, +} from "./session" + +export type { TurboSubmitStartEvent, TurboSubmitEndEvent } from "./drive/form_submission" +export type { TurboFrameMissingEvent } from "./frames/frame_controller" + +export { StreamActions } from "./streams/stream_actions" +export type { TurboStreamAction, TurboStreamActions } from "./streams/stream_actions" /** * Starts the main session. @@ -78,8 +100,13 @@ export function renderStreamMessage(message: StreamMessage | string) { /** * Removes all entries from the Turbo Drive page cache. * Call this when state has changed on the server that may affect cached pages. + * + * @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()` */ export function clearCache() { + console.warn( + "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`" + ) session.clearCache() } @@ -96,3 +123,13 @@ export function clearCache() { export function setProgressBarDelay(delay: number) { session.setProgressBarDelay(delay) } + +export function setConfirmMethod( + confirmMethod: (message: string, element: HTMLFormElement, submitter: HTMLElement | undefined) => Promise +) { + FormSubmission.confirmMethod = confirmMethod +} + +export function setFormMode(mode: FormMode) { + session.setFormMode(mode) +} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/native/adapter.ts b/_vendor/github.com/bep/turbo/v7/src/core/native/adapter.ts index c675bf325bb..3ecfd9b229a 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/native/adapter.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/native/adapter.ts @@ -1,5 +1,6 @@ import { Visit, VisitOptions } from "../drive/visit" import { FormSubmission } from "../drive/form_submission" +import { ReloadReason } from "./browser_adapter" export interface Adapter { visitProposedToLocation(location: URL, options?: Partial): void @@ -13,5 +14,5 @@ export interface Adapter { visitRendered(visit: Visit): void formSubmissionStarted?(formSubmission: FormSubmission): void formSubmissionFinished?(formSubmission: FormSubmission): void - pageInvalidated(): void + pageInvalidated(reason: ReloadReason): void } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/native/browser_adapter.ts b/_vendor/github.com/bep/turbo/v7/src/core/native/browser_adapter.ts index 7bf8a40dc71..ca268e341d7 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/native/browser_adapter.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/native/browser_adapter.ts @@ -3,28 +3,35 @@ import { ProgressBar } from "../drive/progress_bar" import { SystemStatusCode, Visit, VisitOptions } from "../drive/visit" import { FormSubmission } from "../drive/form_submission" import { Session } from "../session" -import { uuid } from "../../util" +import { uuid, dispatch } from "../../util" + +export type ReloadReason = StructuredReason | undefined +interface StructuredReason { + reason: string + context?: { [key: string]: any } +} export class BrowserAdapter implements Adapter { readonly session: Session - readonly progressBar = new ProgressBar + readonly progressBar = new ProgressBar() visitProgressBarTimeout?: number formProgressBarTimeout?: number + location?: URL constructor(session: Session) { this.session = session } visitProposedToLocation(location: URL, options?: Partial) { - this.navigator.startVisit(location, uuid(), options) + this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options) } visitStarted(visit: Visit) { + this.location = visit.location + visit.loadCachedSnapshot() visit.issueRequest() - visit.changeHistory() visit.goToSamePageAnchor() - visit.loadCachedSnapshot() } visitRequestStarted(visit: Visit) { @@ -45,39 +52,38 @@ export class BrowserAdapter implements Adapter { case SystemStatusCode.networkFailure: case SystemStatusCode.timeoutFailure: case SystemStatusCode.contentTypeMismatch: - return this.reload() + return this.reload({ + reason: "request_failed", + context: { + statusCode, + }, + }) default: return visit.loadResponse() } } - visitRequestFinished(visit: Visit) { + visitRequestFinished(_visit: Visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } - visitCompleted(visit: Visit) { + visitCompleted(_visit: Visit) {} + pageInvalidated(reason: ReloadReason) { + this.reload(reason) } - pageInvalidated() { - this.reload() - } + visitFailed(_visit: Visit) {} - visitFailed(visit: Visit) { + visitRendered(_visit: Visit) {} - } - - visitRendered(visit: Visit) { - - } - - formSubmissionStarted(formSubmission: FormSubmission) { + formSubmissionStarted(_formSubmission: FormSubmission) { this.progressBar.setValue(0) this.showFormProgressBarAfterDelay() } - formSubmissionFinished(formSubmission: FormSubmission) { + formSubmissionFinished(_formSubmission: FormSubmission) { this.progressBar.setValue(1) this.hideFormProgressBar() } @@ -114,8 +120,10 @@ export class BrowserAdapter implements Adapter { this.progressBar.show() } - reload() { - window.location.reload() + reload(reason: ReloadReason) { + dispatch("turbo:reload", { detail: reason }) + + window.location.href = this.location?.toString() || window.location.href } get navigator() { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/renderer.ts b/_vendor/github.com/bep/turbo/v7/src/core/renderer.ts index 981bd767637..e77be9d3470 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/renderer.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/renderer.ts @@ -1,29 +1,41 @@ -import { Bardo } from "./bardo" +import { Bardo, BardoDelegate } from "./bardo" import { Snapshot } from "./snapshot" +import { ReloadReason } from "./native/browser_adapter" type ResolvingFunctions = { resolve(value: T | PromiseLike): void reject(reason?: any): void } -export abstract class Renderer = Snapshot> { +export type Render = (currentElement: E, newElement: E) => void + +export abstract class Renderer = Snapshot> implements BardoDelegate { readonly currentSnapshot: S readonly newSnapshot: S readonly isPreview: boolean + readonly willRender: boolean readonly promise: Promise + renderElement: Render private resolvingFunctions?: ResolvingFunctions + private activeElement: Element | null = null - constructor(currentSnapshot: S, newSnapshot: S, isPreview: boolean) { + constructor(currentSnapshot: S, newSnapshot: S, renderElement: Render, isPreview: boolean, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview - this.promise = new Promise((resolve, reject) => this.resolvingFunctions = { resolve, reject }) + this.willRender = willRender + this.renderElement = renderElement + this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) } get shouldRender() { return true } + get reloadReason(): ReloadReason { + return + } + prepareToRender() { return } @@ -37,23 +49,8 @@ export abstract class Renderer = Snapsh } } - createScriptElement(element: Element) { - if (element.getAttribute("data-turbo-eval") == "false") { - return element - } else { - const createdScriptElement = document.createElement("script") - if (this.cspNonce) { - createdScriptElement.nonce = this.cspNonce - } - createdScriptElement.textContent = element.textContent - createdScriptElement.async = false - copyElementAttributes(createdScriptElement, element) - return createdScriptElement - } - } - - preservingPermanentElements(callback: () => void) { - Bardo.preservingPermanentElements(this.permanentElementMap, callback) + async preservingPermanentElements(callback: () => void) { + await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback) } focusFirstAutofocusableElement() { @@ -63,6 +60,24 @@ export abstract class Renderer = Snapsh } } + // Bardo delegate + + enteringBardo(currentPermanentElement: Element) { + if (this.activeElement) return + + if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { + this.activeElement = this.currentSnapshot.activeElement + } + } + + leavingBardo(currentPermanentElement: Element) { + if (currentPermanentElement.contains(this.activeElement) && this.activeElement instanceof HTMLElement) { + this.activeElement.focus() + + this.activeElement = null + } + } + get connectedSnapshot() { return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot } @@ -78,16 +93,6 @@ export abstract class Renderer = Snapsh get permanentElementMap() { return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) } - - get cspNonce() { - return document.head.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") - } -} - -function copyElementAttributes(destinationElement: Element, sourceElement: Element) { - for (const { name, value } of [ ...sourceElement.attributes ]) { - destinationElement.setAttribute(name, value) - } } function elementIsFocusable(element: any): element is { focus: () => void } { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/session.ts b/_vendor/github.com/bep/turbo/v7/src/core/session.ts index cc77980ce05..d988ef3bb3a 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/session.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/session.ts @@ -1,56 +1,86 @@ import { Adapter } from "./native/adapter" -import { BrowserAdapter } from "./native/browser_adapter" +import { BrowserAdapter, ReloadReason } from "./native/browser_adapter" import { CacheObserver } from "../observers/cache_observer" import { FormSubmitObserver, FormSubmitObserverDelegate } from "../observers/form_submit_observer" import { FrameRedirector } from "./frames/frame_redirector" import { History, HistoryDelegate } from "./drive/history" import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer" -import { expandURL, isPrefixedBy, isHTML, Locatable } from "./url" +import { FormLinkClickObserver, FormLinkClickObserverDelegate } from "../observers/form_link_click_observer" +import { getAction, expandURL, locationIsVisitable, Locatable } from "./url" import { Navigator, NavigatorDelegate } from "./drive/navigator" import { PageObserver, PageObserverDelegate } from "../observers/page_observer" 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 { Action, Position, StreamSource, isAction } from "./types" -import { dispatch } from "../util" -import { PageView, PageViewDelegate } from "./drive/page_view" +import { Action, Position, StreamSource } from "./types" +import { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markAsBusy } from "../util" +import { PageView, PageViewDelegate, PageViewRenderOptions } from "./drive/page_view" import { Visit, VisitOptions } from "./drive/visit" import { PageSnapshot } from "./drive/page_snapshot" import { FrameElement } from "../elements/frame_element" +import { FrameViewRenderOptions } from "./frames/frame_view" import { FetchResponse } from "../http/fetch_response" - -export type TimingData = {} - -export class Session implements FormSubmitObserverDelegate, HistoryDelegate, LinkClickObserverDelegate, NavigatorDelegate, PageObserverDelegate, PageViewDelegate { +import { Preloader, PreloaderDelegate } from "./drive/preloader" + +export type FormMode = "on" | "off" | "optin" +export type TimingData = unknown +export type TurboBeforeCacheEvent = CustomEvent +export type TurboBeforeRenderEvent = CustomEvent<{ newBody: HTMLBodyElement } & PageViewRenderOptions> +export type TurboBeforeVisitEvent = CustomEvent<{ url: string }> +export type TurboClickEvent = CustomEvent<{ url: string; originalEvent: MouseEvent }> +export type TurboFrameLoadEvent = CustomEvent +export type TurboBeforeFrameRenderEvent = CustomEvent<{ newFrame: FrameElement } & FrameViewRenderOptions> +export type TurboFrameRenderEvent = CustomEvent<{ fetchResponse: FetchResponse }> +export type TurboLoadEvent = CustomEvent<{ url: string; timing: TimingData }> +export type TurboRenderEvent = CustomEvent +export type TurboVisitEvent = CustomEvent<{ url: string; action: Action }> + +export class Session + implements + FormSubmitObserverDelegate, + HistoryDelegate, + FormLinkClickObserverDelegate, + LinkClickObserverDelegate, + NavigatorDelegate, + PageObserverDelegate, + PageViewDelegate, + PreloaderDelegate +{ readonly navigator = new Navigator(this) readonly history = new History(this) - readonly view = new PageView(this, document.documentElement) + readonly preloader = new Preloader(this) + readonly view = new PageView(this, document.documentElement as HTMLBodyElement) adapter: Adapter = new BrowserAdapter(this) readonly pageObserver = new PageObserver(this) readonly cacheObserver = new CacheObserver() - readonly linkClickObserver = new LinkClickObserver(this) - readonly formSubmitObserver = new FormSubmitObserver(this) + readonly linkClickObserver = new LinkClickObserver(this, window) + readonly formSubmitObserver = new FormSubmitObserver(this, document) readonly scrollObserver = new ScrollObserver(this) readonly streamObserver = new StreamObserver(this) - - readonly frameRedirector = new FrameRedirector(document.documentElement) + readonly formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) + readonly frameRedirector = new FrameRedirector(this, document.documentElement) + readonly streamMessageRenderer = new StreamMessageRenderer() drive = true enabled = true progressBarDelay = 500 started = false + formMode: FormMode = "on" start() { if (!this.started) { this.pageObserver.start() this.cacheObserver.start() + this.formLinkClickObserver.start() this.linkClickObserver.start() this.formSubmitObserver.start() this.scrollObserver.start() this.streamObserver.start() this.frameRedirector.start() this.history.start() + this.preloader.start() this.started = true this.enabled = true } @@ -64,6 +94,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin if (this.started) { this.pageObserver.stop() this.cacheObserver.stop() + this.formLinkClickObserver.stop() this.linkClickObserver.stop() this.formSubmitObserver.stop() this.scrollObserver.stop() @@ -79,7 +110,14 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } visit(location: Locatable, options: Partial = {}) { - this.navigator.proposeVisit(expandURL(location), options) + const frameElement = options.frame ? document.getElementById(options.frame) : null + + if (frameElement instanceof FrameElement) { + frameElement.src = location.toString() + frameElement.loaded + } else { + this.navigator.proposeVisit(expandURL(location), options) + } } connectStreamSource(source: StreamSource) { @@ -91,7 +129,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } renderStreamMessage(message: StreamMessage | string) { - document.documentElement.appendChild(StreamMessage.wrap(message).fragment) + this.streamMessageRenderer.render(StreamMessage.wrap(message)) } clearCache() { @@ -102,6 +140,10 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin this.progressBarDelay = delay } + setFormMode(mode: FormMode) { + this.formMode = mode + } + get location() { return this.history.location } @@ -114,9 +156,14 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin historyPoppedToLocationWithRestorationIdentifier(location: URL, restorationIdentifier: string) { if (this.enabled) { - this.navigator.startVisit(location, restorationIdentifier, { action: "restore", historyChanged: true }) + this.navigator.startVisit(location, restorationIdentifier, { + action: "restore", + historyChanged: true, + }) } else { - this.adapter.pageInvalidated() + this.adapter.pageInvalidated({ + reason: "turbo_disabled", + }) } } @@ -126,36 +173,31 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin this.history.updateRestorationData({ scrollPosition: position }) } + // Form click observer delegate + + willSubmitFormLinkToLocation(link: Element, location: URL): boolean { + return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) + } + + submittedFormLinkToLocation() {} + // Link click observer delegate - willFollowLinkToLocation(link: Element, location: URL) { - return this.elementDriveEnabled(link) - && this.locationIsVisitable(location) - && this.applicationAllowsFollowingLinkToLocation(link, location) + willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent) { + return ( + this.elementIsNavigatable(link) && + locationIsVisitable(location, this.snapshot.rootLocation) && + this.applicationAllowsFollowingLinkToLocation(link, location, event) + ) } followedLinkToLocation(link: Element, location: URL) { const action = this.getActionForLink(link) - this.convertLinkWithMethodClickToFormSubmission(link) || this.visit(location.href, { action }) - } - - convertLinkWithMethodClickToFormSubmission(link: Element) { - const linkMethod = link.getAttribute("data-turbo-method") - - if (linkMethod) { - const form = document.createElement("form") - form.method = linkMethod - form.action = link.getAttribute("href") || "undefined" - form.hidden = true + const acceptsStreamResponse = link.hasAttribute("data-turbo-stream") - link.parentNode?.insertBefore(form, link) - return dispatch("submit", { cancelable: true, target: form }) - } else { - return false - } + this.visit(location.href, { action, acceptsStreamResponse }) } - // Navigator delegate allowsVisitingLocationWithAction(location: URL, action?: Action) { @@ -168,6 +210,9 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } visitStarted(visit: Visit) { + if (!visit.acceptsStreamResponse) { + markAsBusy(document.documentElement) + } extendURLWithDeprecatedProperties(visit.location) if (!visit.silent) { this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) @@ -175,6 +220,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } visitCompleted(visit: Visit) { + clearBusyState(document.documentElement) this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) } @@ -189,7 +235,12 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin // Form submit observer delegate willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean { - return this.elementDriveEnabled(form) && (!submitter || this.elementDriveEnabled(submitter)) + const action = getAction(form, submitter) + + return ( + this.submissionIsNavigatable(form, submitter) && + locationIsVisitable(expandURL(action), this.snapshot.rootLocation) + ) } formSubmitted(form: HTMLFormElement, submitter?: HTMLElement) { @@ -225,18 +276,31 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } } - allowsImmediateRender({ element }: PageSnapshot, resume: (value: any) => void) { - const event = this.notifyApplicationBeforeRender(element, resume) - return !event.defaultPrevented + allowsImmediateRender({ element }: PageSnapshot, options: PageViewRenderOptions) { + const event = this.notifyApplicationBeforeRender(element, options) + const { + defaultPrevented, + detail: { render }, + } = event + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render + } + + return !defaultPrevented } - viewRenderedSnapshot(snapshot: PageSnapshot, isPreview: boolean) { + viewRenderedSnapshot(_snapshot: PageSnapshot, _isPreview: boolean) { this.view.lastRenderedLocation = this.history.location this.notifyApplicationAfterRender() } - viewInvalidated() { - this.adapter.pageInvalidated() + preloadOnLoadLinksForView(element: Element) { + this.preloader.preloadOnLoadLinksForView(element) + } + + viewInvalidated(reason: ReloadReason) { + this.adapter.pageInvalidated(reason) } // Frame element @@ -246,13 +310,13 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } frameRendered(fetchResponse: FetchResponse, frame: FrameElement) { - this.notifyApplicationAfterFrameRender(fetchResponse, frame); + this.notifyApplicationAfterFrameRender(fetchResponse, frame) } // Application events - applicationAllowsFollowingLinkToLocation(link: Element, location: URL) { - const event = this.notifyApplicationAfterClickingLinkToLocation(link, location) + applicationAllowsFollowingLinkToLocation(link: Element, location: URL, ev: MouseEvent) { + const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) return !event.defaultPrevented } @@ -261,61 +325,97 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin return !event.defaultPrevented } - notifyApplicationAfterClickingLinkToLocation(link: Element, location: URL) { - return dispatch("turbo:click", { target: link, detail: { url: location.href }, cancelable: true }) + notifyApplicationAfterClickingLinkToLocation(link: Element, location: URL, event: MouseEvent) { + return dispatch("turbo:click", { + target: link, + detail: { url: location.href, originalEvent: event }, + cancelable: true, + }) } notifyApplicationBeforeVisitingLocation(location: URL) { - return dispatch("turbo:before-visit", { detail: { url: location.href }, cancelable: true }) + return dispatch("turbo:before-visit", { + detail: { url: location.href }, + cancelable: true, + }) } notifyApplicationAfterVisitingLocation(location: URL, action: Action) { - return dispatch("turbo:visit", { detail: { url: location.href, action } }) + return dispatch("turbo:visit", { detail: { url: location.href, action } }) } notifyApplicationBeforeCachingSnapshot() { - return dispatch("turbo:before-cache") + return dispatch("turbo:before-cache") } - notifyApplicationBeforeRender(newBody: HTMLBodyElement, resume: (value: any) => void) { - return dispatch("turbo:before-render", { detail: { newBody, resume }, cancelable: true }) + notifyApplicationBeforeRender(newBody: HTMLBodyElement, options: PageViewRenderOptions) { + return dispatch("turbo:before-render", { + detail: { newBody, ...options }, + cancelable: true, + }) } notifyApplicationAfterRender() { - return dispatch("turbo:render") + return dispatch("turbo:render") } notifyApplicationAfterPageLoad(timing: TimingData = {}) { - return dispatch("turbo:load", { detail: { url: this.location.href, timing }}) + return dispatch("turbo:load", { + detail: { url: this.location.href, timing }, + }) } notifyApplicationAfterVisitingSamePageLocation(oldURL: URL, newURL: URL) { - dispatchEvent(new HashChangeEvent("hashchange", { oldURL: oldURL.toString(), newURL: newURL.toString() })) + dispatchEvent( + new HashChangeEvent("hashchange", { + oldURL: oldURL.toString(), + newURL: newURL.toString(), + }) + ) } notifyApplicationAfterFrameLoad(frame: FrameElement) { - return dispatch("turbo:frame-load", { target: frame }) + return dispatch("turbo:frame-load", { target: frame }) } notifyApplicationAfterFrameRender(fetchResponse: FetchResponse, frame: FrameElement) { - return dispatch("turbo:frame-render", { detail: { fetchResponse }, target: frame, cancelable: true }) + return dispatch("turbo:frame-render", { + detail: { fetchResponse }, + target: frame, + cancelable: true, + }) } // Helpers - elementDriveEnabled(element?: Element) { - const container = element?.closest("[data-turbo]") + submissionIsNavigatable(form: HTMLFormElement, submitter?: HTMLElement): boolean { + if (this.formMode == "off") { + return false + } else { + const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true - // Check if Drive is enabled on the session. - if (this.drive) { - // Drive should be enabled by default, unless `data-turbo="false"`. + if (this.formMode == "optin") { + return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null + } else { + return submitterIsNavigatable && this.elementIsNavigatable(form) + } + } + } + + elementIsNavigatable(element: Element): boolean { + const container = findClosestRecursively(element, "[data-turbo]") + const withinFrame = findClosestRecursively(element, "turbo-frame") + + // Check if Drive is enabled on the session or we're within a Frame. + if (this.drive || withinFrame) { + // Element is navigatable by default, unless `data-turbo="false"`. if (container) { return container.getAttribute("data-turbo") != "false" } else { return true } } else { - // Drive should be disabled by default, unless `data-turbo="true"`. + // Element isn't navigatable by default, unless `data-turbo="true"`. if (container) { return container.getAttribute("data-turbo") == "true" } else { @@ -327,12 +427,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin // Private getActionForLink(link: Element): Action { - const action = link.getAttribute("data-turbo-action") - return isAction(action) ? action : "advance" - } - - locationIsVisitable(location: URL) { - return isPrefixedBy(location, this.snapshot.rootLocation) && isHTML(location) + return getVisitAction(link) || "advance" } get snapshot() { @@ -359,6 +454,6 @@ const deprecatedLocationPropertyDescriptors = { absoluteURL: { get() { return this.toString() - } - } + }, + }, } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/snapshot.ts b/_vendor/github.com/bep/turbo/v7/src/core/snapshot.ts index 9cb4ae03fb0..7d54311171c 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/snapshot.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/snapshot.ts @@ -5,8 +5,12 @@ export class Snapshot { this.element = element } + get activeElement() { + return this.element.ownerDocument.activeElement + } + get children() { - return [ ...this.element.children ] + return [...this.element.children] } hasAnchor(anchor: string | undefined) { @@ -22,15 +26,22 @@ export class Snapshot { } get firstAutofocusableElement() { - return this.element.querySelector("[autofocus]") + const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])" + + for (const element of this.element.querySelectorAll("[autofocus]")) { + if (element.closest(inertDisabledOrHidden) == null) return element + else continue + } + + return null } get permanentElements() { - return [ ...this.element.querySelectorAll("[id][data-turbo-permanent]") ] + return queryPermanentElementsAll(this.element) } getPermanentElementById(id: string) { - return this.element.querySelector(`#${id}[data-turbo-permanent]`) + return getPermanentElementById(this.element, id) } getPermanentElementMapForSnapshot(snapshot: Snapshot) { @@ -40,7 +51,7 @@ export class Snapshot { const { id } = currentPermanentElement const newPermanentElement = snapshot.getPermanentElementById(id) if (newPermanentElement) { - permanentElementMap[id] = [ currentPermanentElement, newPermanentElement ] + permanentElementMap[id] = [currentPermanentElement, newPermanentElement] } } @@ -48,4 +59,12 @@ export class Snapshot { } } +export function getPermanentElementById(node: ParentNode, id: string) { + return node.querySelector(`#${id}[data-turbo-permanent]`) +} + +export function queryPermanentElementsAll(node: ParentNode) { + return node.querySelectorAll("[id][data-turbo-permanent]") +} + export type PermanentElementMap = Record diff --git a/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_actions.ts b/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_actions.ts index b7ab78e9eae..e4a619f7c5c 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_actions.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_actions.ts @@ -1,36 +1,39 @@ import { StreamElement } from "../../elements/stream_element" -export const StreamActions: { [action: string]: (this: StreamElement) => void } = { +export type TurboStreamAction = (this: StreamElement) => void +export type TurboStreamActions = { [action: string]: TurboStreamAction } + +export const StreamActions: TurboStreamActions = { after() { - this.targetElements.forEach(e => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) + this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) }, append() { this.removeDuplicateTargetChildren() - this.targetElements.forEach(e => e.append(this.templateContent)) + this.targetElements.forEach((e) => e.append(this.templateContent)) }, before() { - this.targetElements.forEach(e => e.parentElement?.insertBefore(this.templateContent, e)) + this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)) }, prepend() { this.removeDuplicateTargetChildren() - this.targetElements.forEach(e => e.prepend(this.templateContent)) + this.targetElements.forEach((e) => e.prepend(this.templateContent)) }, remove() { - this.targetElements.forEach(e => e.remove()) + this.targetElements.forEach((e) => e.remove()) }, replace() { - this.targetElements.forEach(e => e.replaceWith(this.templateContent)) + this.targetElements.forEach((e) => e.replaceWith(this.templateContent)) }, update() { - this.targetElements.forEach(e => { - e.innerHTML = "" - e.append(this.templateContent) + this.targetElements.forEach((targetElement) => { + targetElement.innerHTML = "" + targetElement.append(this.templateContent) }) - } + }, } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message.ts b/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message.ts index 4983830199b..e6ca389a4f4 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message.ts @@ -1,40 +1,33 @@ import { StreamElement } from "../../elements/stream_element" +import { activateScriptElement, createDocumentFragment } from "../../util" export class StreamMessage { static readonly contentType = "text/vnd.turbo-stream.html" - readonly templateElement = document.createElement("template") + readonly fragment: DocumentFragment static wrap(message: StreamMessage | string) { if (typeof message == "string") { - return new this(message) + return new this(createDocumentFragment(message)) } else { return message } } - constructor(html: string) { - this.templateElement.innerHTML = html + constructor(fragment: DocumentFragment) { + this.fragment = importStreamElements(fragment) } +} + +function importStreamElements(fragment: DocumentFragment): DocumentFragment { + for (const element of fragment.querySelectorAll("turbo-stream")) { + const streamElement = document.importNode(element, true) - get fragment() { - const fragment = document.createDocumentFragment() - for (const element of this.foreignElements) { - fragment.appendChild(document.importNode(element, true)) + for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { + inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)) } - return fragment - } - get foreignElements() { - return this.templateChildren.reduce((streamElements, child) => { - if (child.tagName.toLowerCase() == "turbo-stream") { - return [ ...streamElements, child as StreamElement ] - } else { - return streamElements - } - }, [] as StreamElement[]) + element.replaceWith(streamElement) } - get templateChildren() { - return Array.from(this.templateElement.content.children) - } + return fragment } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message_renderer.ts b/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message_renderer.ts new file mode 100644 index 00000000000..549e4de8df6 --- /dev/null +++ b/_vendor/github.com/bep/turbo/v7/src/core/streams/stream_message_renderer.ts @@ -0,0 +1,36 @@ +import { StreamMessage } from "./stream_message" +import { StreamElement } from "../../elements/stream_element" +import { Bardo, BardoDelegate } from "../bardo" +import { PermanentElementMap, getPermanentElementById, queryPermanentElementsAll } from "../snapshot" + +export class StreamMessageRenderer implements BardoDelegate { + render({ fragment }: StreamMessage) { + Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => + document.documentElement.appendChild(fragment) + ) + } + + enteringBardo(currentPermanentElement: Element, newPermanentElement: Element) { + newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)) + } + + leavingBardo() {} +} + +function getPermanentElementMapForFragment(fragment: DocumentFragment): PermanentElementMap { + const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement) + const permanentElementMap: PermanentElementMap = {} + for (const permanentElementInDocument of permanentElementsInDocument) { + const { id } = permanentElementInDocument + + for (const streamElement of fragment.querySelectorAll("turbo-stream")) { + const elementInStream = getPermanentElementById(streamElement.templateElement.content, id) + + if (elementInStream) { + permanentElementMap[id] = [permanentElementInDocument, elementInStream] + } + } + } + + return permanentElementMap +} diff --git a/_vendor/github.com/bep/turbo/v7/src/core/types.ts b/_vendor/github.com/bep/turbo/v7/src/core/types.ts index 025cad3bb8e..90d1817ea41 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/types.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/types.ts @@ -1,12 +1,16 @@ export type Action = "advance" | "replace" | "restore" -export function isAction(action: any): action is Action { - return action == "advance" || action == "replace" || action == "restore" -} - -export type Position = { x: number, y: number } +export type Position = { x: number; y: number } export type StreamSource = { - addEventListener(type: "message", listener: (event: MessageEvent) => void, options?: boolean | AddEventListenerOptions): void - removeEventListener(type: "message", listener: (event: MessageEvent) => void, options?: boolean | EventListenerOptions): void + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + options?: boolean | AddEventListenerOptions + ): void + removeEventListener( + type: "message", + listener: (event: MessageEvent) => void, + options?: boolean | EventListenerOptions + ): void } diff --git a/_vendor/github.com/bep/turbo/v7/src/core/url.ts b/_vendor/github.com/bep/turbo/v7/src/core/url.ts index 6c96331ce6e..0e45d8f2bb0 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/url.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/url.ts @@ -8,17 +8,24 @@ export function getAnchor(url: URL) { let anchorMatch if (url.hash) { return url.hash.slice(1) - } else if (anchorMatch = url.href.match(/#(.*)$/)) { + // eslint-disable-next-line no-cond-assign + } else if ((anchorMatch = url.href.match(/#(.*)$/))) { return anchorMatch[1] } } +export function getAction(form: HTMLFormElement, submitter?: HTMLElement) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action + + return expandURL(action) +} + export function getExtension(url: URL) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } export function isHTML(url: URL) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml))$/) + return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) } export function isPrefixedBy(baseURL: URL, url: URL) { @@ -26,11 +33,13 @@ export function isPrefixedBy(baseURL: URL, url: URL) { return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) } +export function locationIsVisitable(location: URL, rootLocation: URL) { + return isPrefixedBy(location, rootLocation) && isHTML(location) +} + export function getRequestURL(url: URL) { const anchor = getAnchor(url) - return anchor != null - ? url.href.slice(0, -(anchor.length + 1)) - : url.href + return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href } export function toCacheKey(url: URL) { diff --git a/_vendor/github.com/bep/turbo/v7/src/core/view.ts b/_vendor/github.com/bep/turbo/v7/src/core/view.ts index 1db328cc5be..16834570adc 100644 --- a/_vendor/github.com/bep/turbo/v7/src/core/view.ts +++ b/_vendor/github.com/bep/turbo/v7/src/core/view.ts @@ -1,22 +1,34 @@ -import { Renderer } from "./renderer" +import { ReloadReason } from "./native/browser_adapter" +import { Renderer, Render } from "./renderer" import { Snapshot } from "./snapshot" import { Position } from "./types" import { getAnchor } from "./url" -export interface ViewDelegate { - allowsImmediateRender(snapshot: S, resume: (value: any) => void): boolean +export interface ViewRenderOptions { + resume: (value?: any) => void + render: Render +} + +export interface ViewDelegate> { + allowsImmediateRender(snapshot: S, options: ViewRenderOptions): boolean + preloadOnLoadLinksForView(element: Element): void viewRenderedSnapshot(snapshot: S, isPreview: boolean): void - viewInvalidated(): void + viewInvalidated(reason: ReloadReason): void } -export abstract class View = Snapshot, R extends Renderer = Renderer, D extends ViewDelegate = ViewDelegate> { +export abstract class View< + E extends Element, + S extends Snapshot = Snapshot, + R extends Renderer = Renderer, + D extends ViewDelegate = ViewDelegate +> { readonly delegate: D readonly element: E renderer?: R abstract readonly snapshot: S renderPromise?: Promise - private resolveRenderPromise = (value: any) => {} - private resolveInterceptionPromise = (value: any) => {} + private resolveRenderPromise = (_value: any) => {} + private resolveInterceptionPromise = (_value: any) => {} constructor(delegate: D, element: E) { this.delegate = delegate @@ -73,16 +85,18 @@ export abstract class View = Snapshot this.resolveRenderPromise = resolve) + this.renderPromise = new Promise((resolve) => (this.resolveRenderPromise = resolve)) this.renderer = renderer - this.prepareToRenderSnapshot(renderer) + await this.prepareToRenderSnapshot(renderer) - const renderInterception = new Promise(resolve => this.resolveInterceptionPromise = resolve) - const immediateRender = this.delegate.allowsImmediateRender(snapshot, this.resolveInterceptionPromise) + const renderInterception = new Promise((resolve) => (this.resolveInterceptionPromise = resolve)) + const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement } + const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) this.delegate.viewRenderedSnapshot(snapshot, isPreview) + this.delegate.preloadOnLoadLinksForView(this.element) this.finishRenderingSnapshot(renderer) } finally { delete this.renderer @@ -90,17 +104,17 @@ export abstract class View = Snapshot disabledChanged(): void - formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement): void loadResponse(response: FetchResponse): void + proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement): void + fetchResponseLoaded: (fetchResponse: FetchResponse) => void + visitCachedSnapshot: (snapshot: Snapshot) => void isLoading: boolean } @@ -32,11 +44,11 @@ export interface FrameElementDelegate { export class FrameElement extends HTMLElement { static delegateConstructor: new (element: FrameElement) => FrameElementDelegate - loaded: Promise = Promise.resolve() + loaded: Promise = Promise.resolve() readonly delegate: FrameElementDelegate - static get observedAttributes() { - return ["disabled", "loading", "src"] + static get observedAttributes(): FrameElementObservedAttribute[] { + return ["disabled", "complete", "loading", "src"] } constructor() { @@ -52,15 +64,15 @@ export class FrameElement extends HTMLElement { this.delegate.disconnect() } - reload() { - const { src } = this; - this.src = null; - this.src = src; + reload(): Promise { + return this.delegate.sourceURLReloaded() } attributeChangedCallback(name: string) { if (name == "loading") { this.delegate.loadingStyleChanged() + } else if (name == "complete") { + this.delegate.completeChanged() } else if (name == "src") { this.delegate.sourceURLChanged() } else { @@ -176,7 +188,9 @@ export class FrameElement extends HTMLElement { function frameLoadingStyleFromString(style: string) { switch (style.toLowerCase()) { - case "lazy": return FrameLoadingStyle.lazy - default: return FrameLoadingStyle.eager + case "lazy": + return FrameLoadingStyle.lazy + default: + return FrameLoadingStyle.eager } } diff --git a/_vendor/github.com/bep/turbo/v7/src/elements/index.ts b/_vendor/github.com/bep/turbo/v7/src/elements/index.ts index 34ae5410ad8..ad77c347808 100644 --- a/_vendor/github.com/bep/turbo/v7/src/elements/index.ts +++ b/_vendor/github.com/bep/turbo/v7/src/elements/index.ts @@ -1,11 +1,22 @@ import { FrameController } from "../core/frames/frame_controller" import { FrameElement } from "./frame_element" import { StreamElement } from "./stream_element" +import { StreamSourceElement } from "./stream_source_element" FrameElement.delegateConstructor = FrameController export * from "./frame_element" export * from "./stream_element" +export * from "./stream_source_element" -customElements.define("turbo-frame", FrameElement) -customElements.define("turbo-stream", StreamElement) +if (customElements.get("turbo-frame") === undefined) { + customElements.define("turbo-frame", FrameElement) +} + +if (customElements.get("turbo-stream") === undefined) { + customElements.define("turbo-stream", StreamElement) +} + +if (customElements.get("turbo-stream-source") === undefined) { + customElements.define("turbo-stream-source", StreamSourceElement) +} diff --git a/_vendor/github.com/bep/turbo/v7/src/elements/stream_element.ts b/_vendor/github.com/bep/turbo/v7/src/elements/stream_element.ts index 6e9b5e8eee2..e0213f97ddc 100644 --- a/_vendor/github.com/bep/turbo/v7/src/elements/stream_element.ts +++ b/_vendor/github.com/bep/turbo/v7/src/elements/stream_element.ts @@ -1,6 +1,10 @@ import { StreamActions } from "../core/streams/stream_actions" import { nextAnimationFrame } from "../util" +type Render = (currentElement: StreamElement) => Promise + +export type TurboBeforeStreamRenderEvent = CustomEvent<{ newStream: StreamElement; render: Render }> + //