diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..7cc206998 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +20.5.1 diff --git a/package.json b/package.json index 8ed7e21a3..163bba9af 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "@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", "@typescript-eslint/eslint-plugin": "^5.50.0", "@typescript-eslint/parser": "^5.50.0", @@ -57,13 +56,14 @@ "rollup": "^2.35.1", "ts-node": "^10.9.1", "tslib": "^2.5.0", - "typescript": "^4.9.5" + "typescript": "^5.2.2" }, "scripts": { "clean": "rm -fr dist", "clean:win": "rmdir /s /q dist", - "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", + "prebuild": "yarn clean", + "build": "tsc src/**/*.js --allowJs --declaration true --emitDeclarationOnly true --outDir dist/types && rollup -c", + "build:win": "tsc src/**/*.js --allowJs --declaration true --emitDeclarationOnly true --outDir dist/types & rollup -c", "watch": "rollup -wc", "start": "ts-node -O '{\"module\":\"commonjs\"}' src/tests/server.ts", "test": "yarn test:unit && yarn test:browser", @@ -71,7 +71,7 @@ "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" + "lint": "eslint . --ext .js" }, "engines": { "node": ">= 14" diff --git a/playwright.config.d.ts b/playwright.config.d.ts new file mode 100644 index 000000000..649511208 --- /dev/null +++ b/playwright.config.d.ts @@ -0,0 +1,3 @@ +import { type PlaywrightTestConfig } from "@playwright/test"; +declare const config: PlaywrightTestConfig; +export default config; diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..4b740d842 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,28 @@ +import { devices } from "@playwright/test" + +const config = { + projects: [ + { + name: "chrome", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + ], + retries: 2, + testDir: "./src/tests/", + testMatch: /(functional|integration)\/.*_tests\.ts/, + webServer: { + command: "yarn start", + url: "http://localhost:9000/src/tests/fixtures/test.js", + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: "http://localhost:9000/", + }, +} + +export default config diff --git a/rollup.config.js b/rollup.config.js index 5073079dd..404ba7f1e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,4 @@ import resolve from "@rollup/plugin-node-resolve" -import typescript from "@rollup/plugin-typescript" import { version } from "./package.json" const year = new Date().getFullYear() @@ -7,26 +6,23 @@ const banner = `/*!\nTurbo ${version}\nCopyright © ${year} 37signals LLC\n */` export default [ { - input: "src/index.ts", + input: "src/index.js", output: [ { name: "Turbo", file: "dist/turbo.es2017-umd.js", format: "umd", - banner + banner, }, { file: "dist/turbo.es2017-esm.js", format: "es", - banner - } - ], - plugins: [ - resolve(), - typescript() + banner, + }, ], + plugins: [resolve()], watch: { - include: "src/**" - } - } + include: "src/**", + }, + }, ] diff --git a/src/core/bardo.ts b/src/core/bardo.js similarity index 55% rename from src/core/bardo.ts rename to src/core/bardo.js index bd6b191f0..a4e9cdf5e 100644 --- a/src/core/bardo.ts +++ b/src/core/bardo.js @@ -1,30 +1,28 @@ -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 + /** @readonly */ + permanentElementMap = undefined + /** @readonly */ + delegate = undefined - static async preservingPermanentElements( - delegate: BardoDelegate, - permanentElementMap: PermanentElementMap, - callback: () => void - ) { + /** @static + * @param {BardoDelegate} delegate + * @param {PermanentElementMap} permanentElementMap + * @param {() => void} callback + * @returns {Promise} + */ + static async preservingPermanentElements(delegate, permanentElementMap, callback) { const bardo = new this(delegate, permanentElementMap) bardo.enter() await callback() bardo.leave() } - constructor(delegate: BardoDelegate, permanentElementMap: PermanentElementMap) { + constructor(delegate, permanentElementMap) { this.delegate = delegate this.permanentElementMap = permanentElementMap } + /** @returns {void} */ enter() { for (const id in this.permanentElementMap) { const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id] @@ -33,6 +31,7 @@ export class Bardo { } } + /** @returns {void} */ leave() { for (const id in this.permanentElementMap) { const [currentPermanentElement] = this.permanentElementMap[id] @@ -42,33 +41,50 @@ export class Bardo { } } - replaceNewPermanentElementWithPlaceholder(permanentElement: Element) { + /** @param {Element} permanentElement + * @returns {void} + */ + replaceNewPermanentElementWithPlaceholder(permanentElement) { const placeholder = createPlaceholderForPermanentElement(permanentElement) permanentElement.replaceWith(placeholder) } - replaceCurrentPermanentElementWithClone(permanentElement: Element) { + /** @param {Element} permanentElement + * @returns {void} + */ + replaceCurrentPermanentElementWithClone(permanentElement) { const clone = permanentElement.cloneNode(true) permanentElement.replaceWith(clone) } - replacePlaceholderWithPermanentElement(permanentElement: Element) { + /** @param {Element} permanentElement + * @returns {void} + */ + replacePlaceholderWithPermanentElement(permanentElement) { const placeholder = this.getPlaceholderById(permanentElement.id) placeholder?.replaceWith(permanentElement) } - getPlaceholderById(id: string) { + /** @param {string} id + * @returns {HTMLMetaElement} + */ + getPlaceholderById(id) { return this.placeholders.find((element) => element.content == id) } - get placeholders(): HTMLMetaElement[] { - return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] + get placeholders() { + return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] } } -function createPlaceholderForPermanentElement(permanentElement: Element) { +/** @param {Element} permanentElement + * @returns {HTMLMetaElement} + */ +function createPlaceholderForPermanentElement(permanentElement) { const element = document.createElement("meta") element.setAttribute("name", "turbo-permanent-placeholder") element.setAttribute("content", permanentElement.id) return element } + +/** @typedef {Object} BardoDelegate */ diff --git a/src/core/cache.ts b/src/core/cache.js similarity index 60% rename from src/core/cache.ts rename to src/core/cache.js index 715b7d098..ee6e7920e 100644 --- a/src/core/cache.ts +++ b/src/core/cache.js @@ -1,30 +1,38 @@ -import { Session } from "./session" import { setMetaContent } from "../util" export class Cache { - readonly session: Session + /** @readonly */ + session = undefined - constructor(session: Session) { + constructor(session) { this.session = session } + /** @returns {void} */ clear() { this.session.clearCache() } + /** @returns {void} */ resetCacheControl() { this.setCacheControl("") } + /** @returns {void} */ exemptPageFromCache() { this.setCacheControl("no-cache") } + /** @returns {void} */ exemptPageFromPreview() { this.setCacheControl("no-preview") } - private setCacheControl(value: string) { + /** @private + * @param {string} value + * @returns {void} + */ + setCacheControl(value) { setMetaContent("turbo-cache-control", value) } } diff --git a/src/core/drive/error_renderer.ts b/src/core/drive/error_renderer.js similarity index 72% rename from src/core/drive/error_renderer.ts rename to src/core/drive/error_renderer.js index 22deb43e7..362b17836 100644 --- a/src/core/drive/error_renderer.ts +++ b/src/core/drive/error_renderer.js @@ -1,25 +1,33 @@ -import { PageSnapshot } from "./page_snapshot" import { Renderer } from "../renderer" import { activateScriptElement } from "../../util" -export class ErrorRenderer extends Renderer { - static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { +/** @extends Renderer */ +export class ErrorRenderer extends Renderer { + /** @static + * @param {HTMLBodyElement} currentElement + * @param {HTMLBodyElement} newElement + * @returns {void} + */ + static renderElement(currentElement, newElement) { const { documentElement, body } = document documentElement.replaceChild(newElement, body) } + /** @returns {Promise} */ async render() { this.replaceHeadAndBody() this.activateScriptElements() } + /** @returns {void} */ replaceHeadAndBody() { const { documentElement, head } = document documentElement.replaceChild(this.newHead, head) this.renderElement(this.currentElement, this.newElement) } + /** @returns {void} */ activateScriptElements() { for (const replaceableElement of this.scriptElements) { const parentNode = replaceableElement.parentNode diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.js similarity index 61% rename from src/core/drive/form_submission.ts rename to src/core/drive/form_submission.js index efb75bc01..02049e502 100644 --- a/src/core/drive/form_submission.ts +++ b/src/core/drive/form_submission.js @@ -1,40 +1,29 @@ import { FetchRequest, FetchMethod, fetchMethodFromString } from "../../http/fetch_request" -import { FetchResponse } from "../../http/fetch_response" import { expandURL } from "../url" import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util" import { StreamMessage } from "../streams/stream_message" -export interface FormSubmissionDelegate { - formSubmissionStarted(formSubmission: FormSubmission): void - formSubmissionSucceededWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse): void - formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse): void - formSubmissionErrored(formSubmission: FormSubmission, error: Error): void - formSubmissionFinished(formSubmission: FormSubmission): void -} - -export type FormSubmissionResult = { success: boolean; fetchResponse: FetchResponse } | { success: false; error: Error } - -export enum FormSubmissionState { - initialized, - requesting, - waiting, - receiving, - stopping, - stopped, -} - -enum FormEnctype { - urlEncoded = "application/x-www-form-urlencoded", - 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 { +export var FormSubmissionState +;(function (FormSubmissionState) { + FormSubmissionState[(FormSubmissionState["initialized"] = 0)] = "initialized" + FormSubmissionState[(FormSubmissionState["requesting"] = 1)] = "requesting" + FormSubmissionState[(FormSubmissionState["waiting"] = 2)] = "waiting" + FormSubmissionState[(FormSubmissionState["receiving"] = 3)] = "receiving" + FormSubmissionState[(FormSubmissionState["stopping"] = 4)] = "stopping" + FormSubmissionState[(FormSubmissionState["stopped"] = 5)] = "stopped" +})(FormSubmissionState || (FormSubmissionState = {})) + +var FormEnctype +;(function (FormEnctype) { + FormEnctype["urlEncoded"] = "application/x-www-form-urlencoded" + FormEnctype["multipart"] = "multipart/form-data" + FormEnctype["plain"] = "text/plain" +})(FormEnctype || (FormEnctype = {})) + +/** @param {string} encoding + * @returns {FormEnctype} + */ +function formEnctypeFromString(encoding) { switch (encoding.toLowerCase()) { case FormEnctype.multipart: return FormEnctype.multipart @@ -46,31 +35,38 @@ function formEnctypeFromString(encoding: string): FormEnctype { } export class FormSubmission { - readonly delegate: FormSubmissionDelegate - readonly formElement: HTMLFormElement - readonly submitter?: HTMLElement - readonly formData: FormData - readonly location: URL - readonly fetchRequest: FetchRequest - readonly mustRedirect: boolean + /** @readonly */ + delegate = undefined + /** @readonly */ + formElement = undefined + /** @readonly */ + submitter = undefined + /** @readonly */ + formData = undefined + /** @readonly */ + location = undefined + /** @readonly */ + fetchRequest = undefined + /** @readonly */ + mustRedirect = undefined + /** @default FormSubmissionState.initialized */ state = FormSubmissionState.initialized - result?: FormSubmissionResult - originalSubmitText?: string - - static confirmMethod( - message: string, - _element: HTMLFormElement, - _submitter: HTMLElement | undefined - ): Promise { + /** */ + result = undefined + /** */ + originalSubmitText = undefined + + /** @static + * @param {string} message + * @param {HTMLFormElement} _element + * @param {HTMLElement | undefined} _submitter + * @returns {Promise} + */ + static confirmMethod(message, _element, _submitter) { return Promise.resolve(confirm(message)) } - constructor( - delegate: FormSubmissionDelegate, - formElement: HTMLFormElement, - submitter?: HTMLElement, - mustRedirect = false - ) { + constructor(delegate, formElement, submitter, mustRedirect = false) { this.delegate = delegate this.formElement = formElement this.submitter = submitter @@ -83,12 +79,12 @@ export class FormSubmission { this.mustRedirect = mustRedirect } - get method(): FetchMethod { + get method() { const method = this.submitter?.getAttribute("formmethod") || this.formElement.getAttribute("method") || "" return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get } - get action(): string { + get action() { const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null if (this.submitter?.hasAttribute("formaction")) { @@ -106,7 +102,7 @@ export class FormSubmission { } } - get enctype(): FormEnctype { + get enctype() { return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.formElement.enctype) } @@ -117,11 +113,12 @@ export class FormSubmission { get stringFormData() { return [...this.formData].reduce((entries, [name, value]) => { return entries.concat(typeof value == "string" ? [[name, value]] : []) - }, [] as [string, string][]) + }, []) } // The submission process + /** @returns {Promise} */ async start() { const { initialized, requesting } = FormSubmissionState const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement) @@ -139,6 +136,7 @@ export class FormSubmission { } } + /** @returns {boolean} */ stop() { const { stopping, stopped } = FormSubmissionState if (this.state != stopping && this.state != stopped) { @@ -150,7 +148,10 @@ export class FormSubmission { // Fetch request delegate - prepareRequest(request: FetchRequest) { + /** @param {FetchRequest} request + * @returns {void} + */ + prepareRequest(request) { if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token") if (token) { @@ -163,22 +164,33 @@ export class FormSubmission { } } - requestStarted(_request: FetchRequest) { + /** @param {FetchRequest} _request + * @returns {void} + */ + requestStarted(_request) { this.state = FormSubmissionState.waiting this.submitter?.setAttribute("disabled", "") this.setSubmitsWith() - dispatch("turbo:submit-start", { + dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this }, }) this.delegate.formSubmissionStarted(this) } - requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {void} + */ + requestPreventedHandlingResponse(request, response) { this.result = { success: response.succeeded, fetchResponse: response } } - requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {void} + */ + requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response) } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { @@ -191,21 +203,32 @@ export class FormSubmission { } } - requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {void} + */ + requestFailedWithResponse(request, response) { this.result = { success: false, fetchResponse: response } this.delegate.formSubmissionFailedWithResponse(this, response) } - requestErrored(request: FetchRequest, error: Error) { + /** @param {FetchRequest} request + * @param {Error} error + * @returns {void} + */ + requestErrored(request, error) { this.result = { success: false, error } this.delegate.formSubmissionErrored(this, error) } - requestFinished(_request: FetchRequest) { + /** @param {FetchRequest} _request + * @returns {void} + */ + requestFinished(_request) { this.state = FormSubmissionState.stopped this.submitter?.removeAttribute("disabled") this.resetSubmitterText() - dispatch("turbo:submit-end", { + dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }, }) @@ -214,6 +237,7 @@ export class FormSubmission { // Private + /** @returns {void} */ setSubmitsWith() { if (!this.submitter || !this.submitsWith) return @@ -221,28 +245,35 @@ export class FormSubmission { this.originalSubmitText = this.submitter.innerHTML this.submitter.innerHTML = this.submitsWith } else if (this.submitter.matches("input")) { - const input = this.submitter as HTMLInputElement + const input = this.submitter this.originalSubmitText = input.value input.value = this.submitsWith } } + /** @returns {void} */ 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 + const input = this.submitter input.value = this.originalSubmitText } } - requestMustRedirect(request: FetchRequest) { + /** @param {FetchRequest} request + * @returns {boolean} + */ + requestMustRedirect(request) { return !request.isSafe && this.mustRedirect } - requestAcceptsTurboStreamResponse(request: FetchRequest) { + /** @param {FetchRequest} request + * @returns {any} + */ + requestAcceptsTurboStreamResponse(request) { return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) } @@ -251,7 +282,11 @@ export class FormSubmission { } } -function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData { +/** @param {HTMLFormElement} formElement + * @param {HTMLElement} [submitter] + * @returns {FormData} + */ +function buildFormData(formElement, submitter) { const formData = new FormData(formElement) const name = submitter?.getAttribute("name") const value = submitter?.getAttribute("value") @@ -263,7 +298,10 @@ function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): F return formData } -function getCookieValue(cookieName: string | null) { +/** @param {string | null} cookieName + * @returns {string} + */ +function getCookieValue(cookieName) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split("; ") : [] const cookie = cookies.find((cookie) => cookie.startsWith(cookieName)) @@ -274,11 +312,18 @@ function getCookieValue(cookieName: string | null) { } } -function responseSucceededWithoutRedirect(response: FetchResponse) { +/** @param {FetchResponse} response + * @returns {boolean} + */ +function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected } -function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL { +/** @param {URL} url + * @param {[string, FormDataEntryValue][]} entries + * @returns {URL} + */ +function mergeFormDataEntries(url, entries) { const searchParams = new URLSearchParams() for (const [name, value] of entries) { @@ -291,3 +336,13 @@ function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]) return url } + +/** @typedef {{ success: boolean; fetchResponse: FetchResponse } | { success: false; error: Error }} FormSubmissionResult */ +/** @typedef {CustomEvent<{ formSubmission: FormSubmission }>} TurboSubmitStartEvent */ +/** + * @typedef {CustomEvent< + * { formSubmission: FormSubmission } & { [K in keyof FormSubmissionResult]?: FormSubmissionResult[K] } + * >} TurboSubmitEndEvent + */ + +/** @typedef {Object} FormSubmissionDelegate */ diff --git a/src/core/drive/head_snapshot.js b/src/core/drive/head_snapshot.js new file mode 100644 index 000000000..e37a831c4 --- /dev/null +++ b/src/core/drive/head_snapshot.js @@ -0,0 +1,186 @@ +import { Snapshot } from "../snapshot" + +/** @extends Snapshot */ +export class HeadSnapshot extends Snapshot { + /** @readonly + * @default this.children + * .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], + * }, + * } + * }, {} as ElementDetailMap) + */ + detailsByOuterHTML = this.children + .filter((element) => !elementIsNoscript(element)) + .map((element) => elementWithoutNonce(element)) + .reduce((result, element) => { + const { outerHTML } = element + const details = + outerHTML in result + ? result[outerHTML] + : { + type: elementType(element), + tracked: elementIsTracked(element), + elements: [], + } + return { + ...result, + [outerHTML]: { + ...details, + elements: [...details.elements, element], + }, + } + }, {}) + + get trackedElementSignature() { + return Object.keys(this.detailsByOuterHTML) + .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) + .join("") + } + + /** @param {HeadSnapshot} snapshot + * @returns {HTMLScriptElement[]} + */ + getScriptElementsNotInSnapshot(snapshot) { + return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) + } + + /** @param {HeadSnapshot} snapshot + * @returns {HTMLLinkElement[]} + */ + getStylesheetElementsNotInSnapshot(snapshot) { + return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) + } + + /** @param {ElementType} matchedType + * @param {HeadSnapshot} snapshot + * @returns {T[]} + */ + getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) { + return Object.keys(this.detailsByOuterHTML) + .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)) + .map((outerHTML) => this.detailsByOuterHTML[outerHTML]) + .filter(({ type }) => type == matchedType) + .map(({ elements: [element] }) => element) + } + + get provisionalElements() { + return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { + const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML] + if (type == null && !tracked) { + return [...result, ...elements] + } else if (elements.length > 1) { + return [...result, ...elements.slice(1)] + } else { + return result + } + }, []) + } + + /** @param {string} name + * @returns {string | null} + */ + getMetaValue(name) { + const element = this.findMetaElementByName(name) + return element ? element.getAttribute("content") : null + } + + /** @param {string} name + * @returns {any} + */ + findMetaElementByName(name) { + return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { + const { + elements: [element], + } = this.detailsByOuterHTML[outerHTML] + return elementIsMetaElementWithName(element, name) ? element : result + }, undefined) + } +} + +/** @param {Element} element + * @returns {"script" | "stylesheet"} + */ +function elementType(element) { + if (elementIsScript(element)) { + return "script" + } else if (elementIsStylesheet(element)) { + return "stylesheet" + } +} + +/** @param {Element} element + * @returns {boolean} + */ +function elementIsTracked(element) { + return element.getAttribute("data-turbo-track") == "reload" +} + +/** @param {Element} element + * @returns {boolean} + */ +function elementIsScript(element) { + const tagName = element.localName + return tagName == "script" +} + +/** @param {Element} element + * @returns {boolean} + */ +function elementIsNoscript(element) { + const tagName = element.localName + return tagName == "noscript" +} + +/** @param {Element} element + * @returns {boolean} + */ +function elementIsStylesheet(element) { + const tagName = element.localName + return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet") +} + +/** @param {Element} element + * @param {string} name + * @returns {boolean} + */ +function elementIsMetaElementWithName(element, name) { + const tagName = element.localName + return tagName == "meta" && element.getAttribute("name") == name +} + +/** @param {Element} element + * @returns {Element} + */ +function elementWithoutNonce(element) { + if (element.hasAttribute("nonce")) { + element.setAttribute("nonce", "") + } + + return element +} + +/** @typedef {{ [outerHTML: string]: ElementDetails }} ElementDetailMap */ +/** + * @typedef {{ + * type?: ElementType + * tracked: boolean + * elements: Element[] + * }} ElementDetails + */ +/** @typedef {"script" | "stylesheet"} ElementType */ diff --git a/src/core/drive/head_snapshot.ts b/src/core/drive/head_snapshot.ts deleted file mode 100644 index 3892332c1..000000000 --- a/src/core/drive/head_snapshot.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Snapshot } from "../snapshot" - -type ElementDetailMap = { [outerHTML: string]: ElementDetails } - -type ElementDetails = { - type?: ElementType - tracked: boolean - elements: Element[] -} - -type ElementType = "script" | "stylesheet" - -export class HeadSnapshot extends Snapshot { - readonly detailsByOuterHTML = this.children - .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], - }, - } - }, {} as ElementDetailMap) - - get trackedElementSignature(): string { - return Object.keys(this.detailsByOuterHTML) - .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) - .join("") - } - - getScriptElementsNotInSnapshot(snapshot: HeadSnapshot) { - return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) - } - - getStylesheetElementsNotInSnapshot(snapshot: HeadSnapshot) { - return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) - } - - getElementsMatchingTypeNotInSnapshot(matchedType: ElementType, snapshot: HeadSnapshot): T[] { - return Object.keys(this.detailsByOuterHTML) - .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)) - .map((outerHTML) => this.detailsByOuterHTML[outerHTML]) - .filter(({ type }) => type == matchedType) - .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] - } else if (elements.length > 1) { - return [...result, ...elements.slice(1)] - } else { - return result - } - }, [] as Element[]) - } - - getMetaValue(name: string): string | null { - const element = this.findMetaElementByName(name) - return element ? element.getAttribute("content") : null - } - - findMetaElementByName(name: string) { - return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { - const { - elements: [element], - } = this.detailsByOuterHTML[outerHTML] - return elementIsMetaElementWithName(element, name) ? element : result - }, undefined as Element | undefined) - } -} - -function elementType(element: Element) { - if (elementIsScript(element)) { - return "script" - } else if (elementIsStylesheet(element)) { - return "stylesheet" - } -} - -function elementIsTracked(element: Element) { - return element.getAttribute("data-turbo-track") == "reload" -} - -function elementIsScript(element: Element) { - const tagName = element.localName - return tagName == "script" -} - -function elementIsNoscript(element: Element) { - const tagName = element.localName - return tagName == "noscript" -} - -function elementIsStylesheet(element: Element) { - const tagName = element.localName - return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet") -} - -function elementIsMetaElementWithName(element: Element, name: string) { - const tagName = element.localName - return tagName == "meta" && element.getAttribute("name") == name -} - -function elementWithoutNonce(element: Element) { - if (element.hasAttribute("nonce")) { - element.setAttribute("nonce", "") - } - - return element -} diff --git a/src/core/drive/history.ts b/src/core/drive/history.js similarity index 54% rename from src/core/drive/history.ts rename to src/core/drive/history.js index 7143adb1b..ecaafcae5 100644 --- a/src/core/drive/history.ts +++ b/src/core/drive/history.js @@ -1,31 +1,26 @@ -import { Position } from "../types" import { nextMicrotask, uuid } from "../../util" -export interface HistoryDelegate { - historyPoppedToLocationWithRestorationIdentifier(location: URL, restorationIdentifier: string): void -} - -type HistoryMethod = (this: typeof history, state: any, title: string, url?: string | null | undefined) => void - -export type RestorationData = { scrollPosition?: Position } - -export type RestorationDataMap = { - [restorationIdentifier: string]: RestorationData -} - export class History { - readonly delegate: HistoryDelegate - location!: URL + /** @readonly */ + delegate = undefined + /** */ + location = undefined + /** @default uuid() */ restorationIdentifier = uuid() - restorationData: RestorationDataMap = {} + /** @default {} */ + restorationData = {} + /** @default false */ started = false + /** @default false */ pageLoaded = false - previousScrollRestoration?: ScrollRestoration + /** */ + previousScrollRestoration = undefined - constructor(delegate: HistoryDelegate) { + constructor(delegate) { this.delegate = delegate } + /** @returns {void} */ start() { if (!this.started) { addEventListener("popstate", this.onPopState, false) @@ -35,6 +30,7 @@ export class History { } } + /** @returns {void} */ stop() { if (this.started) { removeEventListener("popstate", this.onPopState, false) @@ -43,15 +39,27 @@ export class History { } } - push(location: URL, restorationIdentifier?: string) { + /** @param {URL} location + * @param {string} [restorationIdentifier] + * @returns {void} + */ + push(location, restorationIdentifier) { this.update(history.pushState, location, restorationIdentifier) } - replace(location: URL, restorationIdentifier?: string) { + /** @param {URL} location + * @param {string} [restorationIdentifier] + * @returns {void} + */ + replace(location, restorationIdentifier) { this.update(history.replaceState, location, restorationIdentifier) } - update(method: HistoryMethod, location: URL, restorationIdentifier = uuid()) { + /** @param {HistoryMethod} method + * @param {URL} location + * @returns {void} + */ + update(method, location, restorationIdentifier = uuid()) { const state = { turbo: { restorationIdentifier } } method.call(history, state, "", location.href) this.location = location @@ -60,11 +68,17 @@ export class History { // Restoration data - getRestorationDataForIdentifier(restorationIdentifier: string): RestorationData { + /** @param {string} restorationIdentifier + * @returns {RestorationData} + */ + getRestorationDataForIdentifier(restorationIdentifier) { return this.restorationData[restorationIdentifier] || {} } - updateRestorationData(additionalData: Partial) { + /** @param {Partial} additionalData + * @returns {void} + */ + updateRestorationData(additionalData) { const { restorationIdentifier } = this const restorationData = this.restorationData[restorationIdentifier] this.restorationData[restorationIdentifier] = { @@ -75,6 +89,7 @@ export class History { // Scroll restoration + /** @returns {void} */ assumeControlOfScrollRestoration() { if (!this.previousScrollRestoration) { this.previousScrollRestoration = history.scrollRestoration ?? "auto" @@ -82,6 +97,7 @@ export class History { } } + /** @returns {void} */ relinquishControlOfScrollRestoration() { if (this.previousScrollRestoration) { history.scrollRestoration = this.previousScrollRestoration @@ -91,7 +107,20 @@ export class History { // Event handlers - onPopState = (event: PopStateEvent) => { + /** + * @default (event: PopStateEvent) => { + * if (this.shouldHandlePopState()) { + * const { turbo } = event.state || {} + * if (turbo) { + * this.location = new URL(window.location.href) + * const { restorationIdentifier } = turbo + * this.restorationIdentifier = restorationIdentifier + * this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier) + * } + * } + * } + */ + onPopState = (event) => { if (this.shouldHandlePopState()) { const { turbo } = event.state || {} if (turbo) { @@ -103,19 +132,37 @@ export class History { } } - onPageLoad = async (_event: Event) => { + /** + * @default async (_event: Event) => { + * await nextMicrotask() + * this.pageLoaded = true + * } + */ + onPageLoad = async (_event) => { await nextMicrotask() this.pageLoaded = true } // Private + /** @returns {boolean} */ shouldHandlePopState() { // Safari dispatches a popstate event after window's load event, ignore it return this.pageIsLoaded() } + /** @returns {boolean} */ pageIsLoaded() { return this.pageLoaded || document.readyState == "complete" } } + +/** @typedef {Class} HistoryMethod */ +/** @typedef {{ scrollPosition?: Position }} RestorationData */ +/** + * @typedef {{ + * [restorationIdentifier: string]: RestorationData + * }} RestorationDataMap + */ + +/** @typedef {Object} HistoryDelegate */ diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.js similarity index 59% rename from src/core/drive/navigator.ts rename to src/core/drive/navigator.js index 7579913f1..50f1c6dc7 100644 --- a/src/core/drive/navigator.ts +++ b/src/core/drive/navigator.js @@ -1,27 +1,26 @@ -import { Action } from "../types" import { getVisitAction } from "../../util" -import { FetchResponse } from "../../http/fetch_response" import { FormSubmission } from "./form_submission" -import { expandURL, getAnchor, getRequestURL, Locatable, locationIsVisitable } from "../url" -import { Visit, VisitDelegate, VisitOptions } from "./visit" +import { expandURL, getAnchor, getRequestURL, locationIsVisitable } from "../url" +import { Visit } from "./visit" import { PageSnapshot } from "./page_snapshot" -export type NavigatorDelegate = VisitDelegate & { - allowsVisitingLocationWithAction(location: URL, action?: Action): boolean - visitProposedToLocation(location: URL, options: Partial): void - notifyApplicationAfterVisitingSamePageLocation(oldURL: URL, newURL: URL): void -} - export class Navigator { - readonly delegate: NavigatorDelegate - formSubmission?: FormSubmission - currentVisit?: Visit - - constructor(delegate: NavigatorDelegate) { + /** @readonly */ + delegate = undefined + /** */ + formSubmission = undefined + /** */ + currentVisit = undefined + + constructor(delegate) { this.delegate = delegate } - proposeVisit(location: URL, options: Partial = {}) { + /** @param {URL} location + * @param {Partial} [options={}] + * @returns {void} + */ + proposeVisit(location, options = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { if (locationIsVisitable(location, this.view.snapshot.rootLocation)) { this.delegate.visitProposedToLocation(location, options) @@ -31,7 +30,12 @@ export class Navigator { } } - startVisit(locatable: Locatable, restorationIdentifier: string, options: Partial = {}) { + /** @param {Locatable} locatable + * @param {string} restorationIdentifier + * @param {Partial} [options={}] + * @returns {void} + */ + startVisit(locatable, restorationIdentifier, options = {}) { this.stop() this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { referrer: this.location, @@ -40,13 +44,18 @@ export class Navigator { this.currentVisit.start() } - submitForm(form: HTMLFormElement, submitter?: HTMLElement) { + /** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {void} + */ + submitForm(form, submitter) { this.stop() this.formSubmission = new FormSubmission(this, form, submitter, true) this.formSubmission.start() } + /** @returns {void} */ stop() { if (this.formSubmission) { this.formSubmission.stop() @@ -73,14 +82,21 @@ export class Navigator { // Form submission delegate - formSubmissionStarted(formSubmission: FormSubmission) { + /** @param {FormSubmission} formSubmission + * @returns {void} + */ + formSubmissionStarted(formSubmission) { // Not all adapters implement formSubmissionStarted if (typeof this.adapter.formSubmissionStarted === "function") { this.adapter.formSubmissionStarted(formSubmission) } } - async formSubmissionSucceededWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { + /** @param {FormSubmission} formSubmission + * @param {FetchResponse} fetchResponse + * @returns {Promise} + */ + async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { if (formSubmission == this.formSubmission) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { @@ -101,7 +117,11 @@ export class Navigator { } } - async formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { + /** @param {FormSubmission} formSubmission + * @param {FetchResponse} fetchResponse + * @returns {Promise} + */ + async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { @@ -116,11 +136,18 @@ export class Navigator { } } - formSubmissionErrored(formSubmission: FormSubmission, error: Error) { + /** @param {FormSubmission} formSubmission + * @param {Error} error + * @returns {void} + */ + formSubmissionErrored(formSubmission, error) { console.error(error) } - formSubmissionFinished(formSubmission: FormSubmission) { + /** @param {FormSubmission} formSubmission + * @returns {void} + */ + formSubmissionFinished(formSubmission) { // Not all adapters implement formSubmissionFinished if (typeof this.adapter.formSubmissionFinished === "function") { this.adapter.formSubmissionFinished(formSubmission) @@ -129,15 +156,25 @@ export class Navigator { // Visit delegate - visitStarted(visit: Visit) { + /** @param {Visit} visit + * @returns {void} + */ + visitStarted(visit) { this.delegate.visitStarted(visit) } - visitCompleted(visit: Visit) { + /** @param {Visit} visit + * @returns {void} + */ + visitCompleted(visit) { this.delegate.visitCompleted(visit) } - locationWithActionIsSamePage(location: URL, action?: Action): boolean { + /** @param {URL} location + * @param {Action} [action] + * @returns {boolean} + */ + locationWithActionIsSamePage(location, action) { const anchor = getAnchor(location) const currentAnchor = getAnchor(this.view.lastRenderedLocation) const isRestorationToTop = action === "restore" && typeof anchor === "undefined" @@ -149,7 +186,11 @@ export class Navigator { ) } - visitScrolledToSamePageLocation(oldURL: URL, newURL: URL) { + /** @param {URL} oldURL + * @param {URL} newURL + * @returns {void} + */ + visitScrolledToSamePageLocation(oldURL, newURL) { this.delegate.visitScrolledToSamePageLocation(oldURL, newURL) } @@ -163,7 +204,18 @@ export class Navigator { return this.history.restorationIdentifier } - getActionForFormSubmission({ submitter, formElement }: FormSubmission): Action { + /** @param {FormSubmission} + * @returns {Action} + */ + getActionForFormSubmission({ submitter, formElement }) { return getVisitAction(submitter, formElement) || "advance" } } + +/** + * @typedef {VisitDelegate & { + * allowsVisitingLocationWithAction(location: URL, action?: Action): boolean + * visitProposedToLocation(location: URL, options: Partial): void + * notifyApplicationAfterVisitingSamePageLocation(oldURL: URL, newURL: URL): void + * }} NavigatorDelegate + */ diff --git a/src/core/drive/page_renderer.ts b/src/core/drive/page_renderer.js similarity index 82% rename from src/core/drive/page_renderer.ts rename to src/core/drive/page_renderer.js index bb401414a..4a0ff52ef 100644 --- a/src/core/drive/page_renderer.ts +++ b/src/core/drive/page_renderer.js @@ -1,10 +1,14 @@ 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) { +/** @extends Renderer */ +export class PageRenderer extends Renderer { + /** @static + * @param {HTMLBodyElement} currentElement + * @param {HTMLBodyElement} newElement + * @returns {void} + */ + static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement) } else { @@ -16,7 +20,7 @@ export class PageRenderer extends Renderer { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } - get reloadReason(): ReloadReason { + get reloadReason() { if (!this.newSnapshot.isVisitable) { return { reason: "turbo_visit_control_is_reload", @@ -30,16 +34,19 @@ export class PageRenderer extends Renderer { } } + /** @returns {Promise} */ async prepareToRender() { await this.mergeHead() } + /** @returns {Promise} */ async render() { if (this.willRender) { await this.replaceBody() } } + /** @returns {void} */ finishRendering() { super.finishRendering() if (!this.isPreview) { @@ -59,6 +66,7 @@ export class PageRenderer extends Renderer { return this.newSnapshot.element } + /** @returns {Promise} */ async mergeHead() { const mergedHeadElements = this.mergeProvisionalElements() const newStylesheetElements = this.copyNewHeadStylesheetElements() @@ -67,6 +75,7 @@ export class PageRenderer extends Renderer { await newStylesheetElements } + /** @returns {Promise} */ async replaceBody() { await this.preservingPermanentElements(async () => { this.activateNewBody() @@ -78,11 +87,12 @@ export class PageRenderer extends Renderer { return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature } + /** @returns {Promise} */ async copyNewHeadStylesheetElements() { const loadingElements = [] for (const element of this.newHeadStylesheetElements) { - loadingElements.push(waitForLoad(element as HTMLLinkElement)) + loadingElements.push(waitForLoad(element)) document.head.appendChild(element) } @@ -90,12 +100,14 @@ export class PageRenderer extends Renderer { await Promise.all(loadingElements) } + /** @returns {void} */ copyNewHeadScriptElements() { for (const element of this.newHeadScriptElements) { document.head.appendChild(activateScriptElement(element)) } } + /** @returns {Promise} */ async mergeProvisionalElements() { const newHeadElements = [...this.newHeadProvisionalElements] @@ -110,7 +122,11 @@ export class PageRenderer extends Renderer { } } - isCurrentElementInElementList(element: Element, elementList: Element[]) { + /** @param {Element} element + * @param {Element[]} elementList + * @returns {boolean} + */ + isCurrentElementInElementList(element, elementList) { for (const [index, newElement] of elementList.entries()) { // if title element... if (element.tagName == "TITLE") { @@ -133,23 +149,27 @@ export class PageRenderer extends Renderer { return false } + /** @returns {void} */ removeCurrentHeadProvisionalElements() { for (const element of this.currentHeadProvisionalElements) { document.head.removeChild(element) } } + /** @returns {void} */ copyNewHeadProvisionalElements() { for (const element of this.newHeadProvisionalElements) { document.head.appendChild(element) } } + /** @returns {void} */ activateNewBody() { document.adoptNode(this.newElement) this.activateNewBodyScriptElements() } + /** @returns {void} */ activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement) @@ -157,6 +177,7 @@ export class PageRenderer extends Renderer { } } + /** @returns {Promise} */ async assignNewBody() { await this.renderElement(this.currentElement, this.newElement) } diff --git a/src/core/drive/page_snapshot.ts b/src/core/drive/page_snapshot.js similarity index 73% rename from src/core/drive/page_snapshot.ts rename to src/core/drive/page_snapshot.js index e8b7041a1..4a2195e34 100644 --- a/src/core/drive/page_snapshot.ts +++ b/src/core/drive/page_snapshot.js @@ -3,26 +3,40 @@ import { Snapshot } from "../snapshot" import { expandURL } from "../url" import { HeadSnapshot } from "./head_snapshot" -export class PageSnapshot extends Snapshot { +/** @extends Snapshot */ +export class PageSnapshot extends Snapshot { + /** @static + * @returns {PageSnapshot} + */ static fromHTMLString(html = "") { return this.fromDocument(parseHTMLDocument(html)) } - static fromElement(element: Element) { + /** @static + * @param {Element} element + * @returns {PageSnapshot} + */ + static fromElement(element) { return this.fromDocument(element.ownerDocument) } - static fromDocument({ head, body }: Document) { - return new this(body as HTMLBodyElement, new HeadSnapshot(head)) + /** @static + * @param {Document} + * @returns {PageSnapshot} + */ + static fromDocument({ head, body }) { + return new this(body, new HeadSnapshot(head)) } - readonly headSnapshot: HeadSnapshot + /** @readonly */ + headSnapshot = undefined - constructor(element: HTMLBodyElement, headSnapshot: HeadSnapshot) { + constructor(element, headSnapshot) { super(element) this.headSnapshot = headSnapshot } + /** @returns {PageSnapshot} */ clone() { const clonedElement = this.element.cloneNode(true) @@ -35,7 +49,7 @@ export class PageSnapshot extends Snapshot { for (const option of source.selectedOptions) clone.options[option.index].selected = true } - for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { + for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { clonedPasswordInput.value = "" } @@ -73,7 +87,10 @@ export class PageSnapshot extends Snapshot { // Private - getSetting(name: string) { + /** @param {string} name + * @returns {any} + */ + getSetting(name) { return this.headSnapshot.getMetaValue(`turbo-${name}`) } } diff --git a/src/core/drive/page_view.ts b/src/core/drive/page_view.js similarity index 53% rename from src/core/drive/page_view.ts rename to src/core/drive/page_view.js index dba0defbb..663c5de6b 100644 --- a/src/core/drive/page_view.ts +++ b/src/core/drive/page_view.js @@ -1,29 +1,33 @@ import { nextEventLoopTick } from "../../util" -import { View, ViewDelegate, ViewRenderOptions } from "../view" +import { View } 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 type PageViewRenderOptions = ViewRenderOptions - -export interface PageViewDelegate extends ViewDelegate { - viewWillCacheSnapshot(): void -} - -type PageViewRenderer = PageRenderer | ErrorRenderer - -export class PageView extends View { - readonly snapshotCache = new SnapshotCache(10) +/** @extends View */ +export class PageView extends View { + /** @readonly + * @default new SnapshotCache(10) + */ + snapshotCache = new SnapshotCache(10) + /** @default new URL(location.href) */ lastRenderedLocation = new URL(location.href) + /** @default false */ forceReloaded = false - shouldTransitionTo(newSnapshot: PageSnapshot) { + /** @param {PageSnapshot} newSnapshot + * @returns {any} + */ + shouldTransitionTo(newSnapshot) { return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions } - renderPage(snapshot: PageSnapshot, isPreview = false, willRender = true, visit?: Visit) { + /** @param {PageSnapshot} snapshot + * @param {Visit} [visit] + * @returns {any} + */ + renderPage(snapshot, isPreview = false, willRender = true, visit) { const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) if (!renderer.shouldRender) { @@ -35,17 +39,25 @@ export class PageView extends View} + */ + async cacheSnapshot(snapshot = this.snapshot) { if (snapshot.isCacheable) { this.delegate.viewWillCacheSnapshot() const { lastRenderedLocation: location } = this @@ -56,7 +68,10 @@ export class PageView extends View} PageViewRenderOptions */ +/** @typedef {PageRenderer | ErrorRenderer} PageViewRenderer */ + +/** @typedef {Object} PageViewDelegate */ diff --git a/src/core/drive/preloader.ts b/src/core/drive/preloader.js similarity index 62% rename from src/core/drive/preloader.ts rename to src/core/drive/preloader.js index 6aaf0b43b..b04561a41 100644 --- a/src/core/drive/preloader.ts +++ b/src/core/drive/preloader.js @@ -1,23 +1,22 @@ -import { Navigator } from "./navigator" import { PageSnapshot } from "./page_snapshot" -import { SnapshotCache } from "./snapshot_cache" - -export interface PreloaderDelegate { - readonly navigator: Navigator -} export class Preloader { - readonly delegate: PreloaderDelegate - readonly selector: string = "a[data-turbo-preload]" - - constructor(delegate: PreloaderDelegate) { + /** @readonly */ + delegate = undefined + /** @readonly + * @default "a[data-turbo-preload]" + */ + selector = "a[data-turbo-preload]" + + constructor(delegate) { this.delegate = delegate } - get snapshotCache(): SnapshotCache { + get snapshotCache() { return this.delegate.navigator.view.snapshotCache } + /** @returns {void} */ start() { if (document.readyState === "loading") { return document.addEventListener("DOMContentLoaded", () => { @@ -28,13 +27,19 @@ export class Preloader { } } - preloadOnLoadLinksForView(element: Element) { - for (const link of element.querySelectorAll(this.selector)) { + /** @param {Element} element + * @returns {void} + */ + preloadOnLoadLinksForView(element) { + for (const link of element.querySelectorAll(this.selector)) { this.preloadURL(link) } } - async preloadURL(link: HTMLAnchorElement) { + /** @param {HTMLAnchorElement} link + * @returns {Promise} + */ + async preloadURL(link) { const location = new URL(link.href) if (this.snapshotCache.has(location)) { @@ -52,3 +57,7 @@ export class Preloader { } } } + +/** @typedef {Object} PreloaderDelegate + * @property {Navigator} navigator + */ diff --git a/src/core/drive/progress_bar.ts b/src/core/drive/progress_bar.js similarity index 77% rename from src/core/drive/progress_bar.ts rename to src/core/drive/progress_bar.js index 9a189be92..9aac0e5c2 100644 --- a/src/core/drive/progress_bar.ts +++ b/src/core/drive/progress_bar.js @@ -1,8 +1,12 @@ import { unindent, getMetaContent } from "../../util" export class ProgressBar { + /** @static + * @default 300 + */ static animationDuration = 300 /*ms*/ + /** @static */ static get defaultCSS() { return unindent` .turbo-progress-bar { @@ -21,12 +25,18 @@ export class ProgressBar { ` } - readonly stylesheetElement: HTMLStyleElement - readonly progressElement: HTMLDivElement + /** @readonly */ + stylesheetElement = undefined + /** @readonly */ + progressElement = undefined + /** @default false */ hiding = false - trickleInterval?: number + /** */ + trickleInterval = undefined + /** @default 0 */ value = 0 + /** @default false */ visible = false constructor() { @@ -36,6 +46,7 @@ export class ProgressBar { this.setValue(0) } + /** @returns {void} */ show() { if (!this.visible) { this.visible = true @@ -44,6 +55,7 @@ export class ProgressBar { } } + /** @returns {void} */ hide() { if (this.visible && !this.hiding) { this.hiding = true @@ -56,17 +68,22 @@ export class ProgressBar { } } - setValue(value: number) { + /** @param {number} value + * @returns {void} + */ + setValue(value) { this.value = value this.refresh() } // Private + /** @returns {void} */ installStylesheetElement() { document.head.insertBefore(this.stylesheetElement, document.head.firstChild) } + /** @returns {void} */ installProgressElement() { this.progressElement.style.width = "0" this.progressElement.style.opacity = "1" @@ -74,38 +91,51 @@ export class ProgressBar { this.refresh() } - fadeProgressElement(callback: () => void) { + /** @param {() => void} callback + * @returns {void} + */ + fadeProgressElement(callback) { this.progressElement.style.opacity = "0" setTimeout(callback, ProgressBar.animationDuration * 1.5) } + /** @returns {void} */ uninstallProgressElement() { if (this.progressElement.parentNode) { document.documentElement.removeChild(this.progressElement) } } + /** @returns {void} */ startTrickling() { if (!this.trickleInterval) { this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration) } } + /** @returns {void} */ stopTrickling() { window.clearInterval(this.trickleInterval) delete this.trickleInterval } + /** + * @default () => { + * this.setValue(this.value + Math.random() / 100) + * } + */ trickle = () => { this.setValue(this.value + Math.random() / 100) } + /** @returns {void} */ refresh() { requestAnimationFrame(() => { this.progressElement.style.width = `${10 + this.value * 90}%` }) } + /** @returns {HTMLStyleElement} */ createStylesheetElement() { const element = document.createElement("style") element.type = "text/css" @@ -116,6 +146,7 @@ export class ProgressBar { return element } + /** @returns {HTMLDivElement} */ createProgressElement() { const element = document.createElement("div") element.className = "turbo-progress-bar" diff --git a/src/core/drive/snapshot_cache.ts b/src/core/drive/snapshot_cache.js similarity index 51% rename from src/core/drive/snapshot_cache.ts rename to src/core/drive/snapshot_cache.js index a93a1bde2..92e16d405 100644 --- a/src/core/drive/snapshot_cache.ts +++ b/src/core/drive/snapshot_cache.js @@ -1,20 +1,30 @@ import { toCacheKey } from "../url" -import { PageSnapshot } from "./page_snapshot" export class SnapshotCache { - readonly keys: string[] = [] - readonly size: number - snapshots: { [url: string]: PageSnapshot } = {} + /** @readonly + * @default [] + */ + keys = [] + /** @readonly */ + size = undefined + /** @default {} */ + snapshots = {} - constructor(size: number) { + constructor(size) { this.size = size } - has(location: URL) { + /** @param {URL} location + * @returns {boolean} + */ + has(location) { return toCacheKey(location) in this.snapshots } - get(location: URL): PageSnapshot | undefined { + /** @param {URL} location + * @returns {PageSnapshot | undefined} + */ + get(location) { if (this.has(location)) { const snapshot = this.read(location) this.touch(location) @@ -22,27 +32,42 @@ export class SnapshotCache { } } - put(location: URL, snapshot: PageSnapshot) { + /** @param {URL} location + * @param {PageSnapshot} snapshot + * @returns {any} + */ + put(location, snapshot) { this.write(location, snapshot) this.touch(location) return snapshot } + /** @returns {void} */ clear() { this.snapshots = {} } // Private - read(location: URL) { + /** @param {URL} location + * @returns {any} + */ + read(location) { return this.snapshots[toCacheKey(location)] } - write(location: URL, snapshot: PageSnapshot) { + /** @param {URL} location + * @param {PageSnapshot} snapshot + * @returns {void} + */ + write(location, snapshot) { this.snapshots[toCacheKey(location)] = snapshot } - touch(location: URL) { + /** @param {URL} location + * @returns {void} + */ + touch(location) { const key = toCacheKey(location) const index = this.keys.indexOf(key) if (index > -1) this.keys.splice(index, 1) @@ -50,6 +75,7 @@ export class SnapshotCache { this.trim() } + /** @returns {void} */ trim() { for (const key of this.keys.splice(this.size)) { delete this.snapshots[key] diff --git a/src/core/drive/view_transitioner.js b/src/core/drive/view_transitioner.js new file mode 100644 index 000000000..00e4f1516 --- /dev/null +++ b/src/core/drive/view_transitioner.js @@ -0,0 +1,32 @@ +export class ViewTransitioner { + /** @private + * @default false + */ + viewTransitionStarted = false + /** @private + * @default Promise.resolve() + */ + lastOperation = Promise.resolve() + + /** @param {boolean} useViewTransition + * @param {() => Promise} render + * @returns {Promise} + */ + renderChange(useViewTransition, render) { + if (useViewTransition && this.viewTransitionsAvailable && !this.viewTransitionStarted) { + this.viewTransitionStarted = true + this.lastOperation = this.lastOperation.then(async () => { + await document.startViewTransition(render).finished + }) + } else { + this.lastOperation = this.lastOperation.then(render) + } + + return this.lastOperation + } + + /** @private */ + get viewTransitionsAvailable() { + return document.startViewTransition + } +} diff --git a/src/core/drive/view_transitioner.ts b/src/core/drive/view_transitioner.ts deleted file mode 100644 index b0ec16cce..000000000 --- a/src/core/drive/view_transitioner.ts +++ /dev/null @@ -1,31 +0,0 @@ -declare global { - type ViewTransition = { - finished: Promise - } - - interface Document { - startViewTransition?(callback: () => Promise): ViewTransition - } -} - -export class ViewTransitioner { - private viewTransitionStarted = false - private lastOperation = Promise.resolve() - - renderChange(useViewTransition: boolean, render: () => Promise) { - if (useViewTransition && this.viewTransitionsAvailable && !this.viewTransitionStarted) { - this.viewTransitionStarted = true - this.lastOperation = this.lastOperation.then(async () => { - await document.startViewTransition!(render).finished - }) - } else { - this.lastOperation = this.lastOperation.then(render) - } - - return this.lastOperation - } - - private get viewTransitionsAvailable() { - return document.startViewTransition - } -} diff --git a/src/core/drive/visit.ts b/src/core/drive/visit.js similarity index 67% rename from src/core/drive/visit.ts rename to src/core/drive/visit.js index 1a311b640..9658f9647 100644 --- a/src/core/drive/visit.ts +++ b/src/core/drive/visit.js @@ -1,61 +1,28 @@ -import { Adapter } from "../native/adapter" -import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request" -import { FetchResponse } from "../../http/fetch_response" -import { History } from "./history" +import { FetchMethod, FetchRequest } from "../../http/fetch_request" import { getAnchor } from "../url" -import { Snapshot } from "../snapshot" import { PageSnapshot } from "./page_snapshot" -import { Action } from "../types" import { getHistoryMethodForAction, uuid } from "../../util" -import { PageView } from "./page_view" import { StreamMessage } from "../streams/stream_message" import { ViewTransitioner } from "./view_transitioner" -export interface VisitDelegate { - readonly adapter: Adapter - readonly history: History - readonly view: PageView - - visitStarted(visit: Visit): void - visitCompleted(visit: Visit): void - locationWithActionIsSamePage(location: URL, action: Action): boolean - visitScrolledToSamePageLocation(oldURL: URL, newURL: URL): void -} - -export enum TimingMetric { - visitStart = "visitStart", - requestStart = "requestStart", - requestEnd = "requestEnd", - visitEnd = "visitEnd", -} - -export type TimingMetrics = Partial<{ [metric in TimingMetric]: any }> - -export enum VisitState { - initialized = "initialized", - started = "started", - canceled = "canceled", - failed = "failed", - completed = "completed", -} - -export type VisitOptions = { - 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 = { +export var TimingMetric +;(function (TimingMetric) { + TimingMetric["visitStart"] = "visitStart" + TimingMetric["requestStart"] = "requestStart" + TimingMetric["requestEnd"] = "requestEnd" + TimingMetric["visitEnd"] = "visitEnd" +})(TimingMetric || (TimingMetric = {})) + +export var VisitState +;(function (VisitState) { + VisitState["initialized"] = "initialized" + VisitState["started"] = "started" + VisitState["canceled"] = "canceled" + VisitState["failed"] = "failed" + VisitState["completed"] = "completed" +})(VisitState || (VisitState = {})) + +const defaultOptions = { action: "advance", historyChanged: false, visitCachedSnapshot: () => {}, @@ -65,52 +32,71 @@ const defaultOptions: VisitOptions = { acceptsStreamResponse: false, } -export type VisitResponse = { - statusCode: number - redirected: boolean - responseHTML?: string -} - -export enum SystemStatusCode { - networkFailure = 0, - timeoutFailure = -1, - contentTypeMismatch = -2, -} - -export class Visit implements FetchRequestDelegate { - readonly delegate: VisitDelegate - 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 - +export var SystemStatusCode +;(function (SystemStatusCode) { + SystemStatusCode[(SystemStatusCode["networkFailure"] = 0)] = "networkFailure" + SystemStatusCode[(SystemStatusCode["timeoutFailure"] = -1)] = "timeoutFailure" + SystemStatusCode[(SystemStatusCode["contentTypeMismatch"] = -2)] = "contentTypeMismatch" +})(SystemStatusCode || (SystemStatusCode = {})) + +export class Visit { + /** @readonly */ + delegate = undefined + /** @readonly + * @default uuid() + */ + identifier = uuid() // Required by turbo-ios + /** @readonly */ + restorationIdentifier = undefined + /** @readonly */ + action = undefined + /** @readonly */ + referrer = undefined + /** @readonly + * @default {} + */ + timingMetrics = {} + /** @readonly */ + visitCachedSnapshot = undefined + /** @readonly */ + willRender = undefined + /** @readonly */ + updateHistory = undefined + + /** @default false */ followedRedirect = false - frame?: number + /** */ + frame = undefined + /** @default false */ historyChanged = false - location: URL - isSamePage: boolean - redirectedToLocation?: URL - request?: FetchRequest - response?: VisitResponse + /** */ + location = undefined + /** */ + isSamePage = undefined + /** */ + redirectedToLocation = undefined + /** */ + request = undefined + /** */ + response = undefined + /** @default false */ scrolled = false + /** @default true */ shouldCacheSnapshot = true + /** @default false */ acceptsStreamResponse = false - snapshotHTML?: string + /** */ + snapshotHTML = undefined + /** @default false */ snapshotCached = false + /** @default VisitState.initialized */ state = VisitState.initialized - snapshot?: PageSnapshot + /** */ + snapshot = undefined + /** @default new ViewTransitioner() */ viewTransitioner = new ViewTransitioner() - constructor( - delegate: VisitDelegate, - location: URL, - restorationIdentifier: string | undefined, - options: Partial = {} - ) { + constructor(delegate, location, restorationIdentifier, options = {}) { this.delegate = delegate this.location = location this.restorationIdentifier = restorationIdentifier || uuid() @@ -166,6 +152,7 @@ export class Visit implements FetchRequestDelegate { return this.isSamePage } + /** @returns {void} */ start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart) @@ -175,6 +162,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ cancel() { if (this.state == VisitState.started) { if (this.request) { @@ -185,6 +173,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ complete() { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd) @@ -198,6 +187,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ fail() { if (this.state == VisitState.started) { this.state = VisitState.failed @@ -206,6 +196,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ changeHistory() { if (!this.historyChanged && this.updateHistory) { const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action @@ -215,6 +206,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest() @@ -224,6 +216,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ simulateRequest() { if (this.response) { this.startRequest() @@ -232,11 +225,13 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ startRequest() { this.recordTimingMetric(TimingMetric.requestStart) this.adapter.visitRequestStarted(this) } + /** @returns {void} */ recordResponse(response = this.response) { this.response = response if (response) { @@ -249,11 +244,13 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ finishRequest() { this.recordTimingMetric(TimingMetric.requestEnd) this.adapter.visitRequestFinished(this) } + /** @returns {void} */ loadResponse() { if (this.response) { const { statusCode, responseHTML } = this.response @@ -276,6 +273,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {any} */ getCachedSnapshot() { const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot() @@ -286,16 +284,19 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {any} */ getPreloadedSnapshot() { if (this.snapshotHTML) { return PageSnapshot.fromHTMLString(this.snapshotHTML) } } + /** @returns {boolean} */ hasCachedSnapshot() { return this.getCachedSnapshot() != null } + /** @returns {void} */ loadCachedSnapshot() { const snapshot = this.getCachedSnapshot() if (snapshot) { @@ -318,6 +319,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ followRedirect() { if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { @@ -330,6 +332,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ goToSamePageAnchor() { if (this.isSamePage) { this.render(async () => { @@ -343,19 +346,31 @@ export class Visit implements FetchRequestDelegate { // Fetch request delegate - prepareRequest(request: FetchRequest) { + /** @param {FetchRequest} request + * @returns {void} + */ + prepareRequest(request) { if (this.acceptsStreamResponse) { request.acceptResponseType(StreamMessage.contentType) } } + /** @returns {void} */ requestStarted() { this.startRequest() } - requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) {} + /** @param {FetchRequest} _request + * @param {FetchResponse} _response + * @returns {void} + */ + requestPreventedHandlingResponse(_request, _response) {} - async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {Promise} + */ + async requestSucceededWithResponse(request, response) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { @@ -369,7 +384,11 @@ export class Visit implements FetchRequestDelegate { } } - async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {Promise} + */ + async requestFailedWithResponse(request, response) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { @@ -382,19 +401,25 @@ export class Visit implements FetchRequestDelegate { } } - requestErrored(_request: FetchRequest, _error: Error) { + /** @param {FetchRequest} _request + * @param {Error} _error + * @returns {void} + */ + requestErrored(_request, _error) { this.recordResponse({ statusCode: SystemStatusCode.networkFailure, redirected: false, }) } + /** @returns {void} */ requestFinished() { this.finishRequest() } // Scrolling + /** @returns {void} */ performScroll() { if (!this.scrolled && !this.view.forceReloaded) { if (this.action == "restore") { @@ -410,6 +435,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {boolean} */ scrollToRestoredPosition() { const { scrollPosition } = this.restorationData if (scrollPosition) { @@ -418,6 +444,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {boolean} */ scrollToAnchor() { const anchor = getAnchor(this.location) if (anchor != null) { @@ -428,17 +455,24 @@ export class Visit implements FetchRequestDelegate { // Instrumentation - recordTimingMetric(metric: TimingMetric) { + /** @param {TimingMetric} metric + * @returns {void} + */ + recordTimingMetric(metric) { this.timingMetrics[metric] = new Date().getTime() } - getTimingMetrics(): TimingMetrics { + /** @returns {TimingMetrics} */ + getTimingMetrics() { return { ...this.timingMetrics } } // Private - getHistoryMethodForAction(action: Action) { + /** @param {Action} action + * @returns {(data: any, unused: string, url?: string | URL) => void} + */ + getHistoryMethodForAction(action) { switch (action) { case "replace": return history.replaceState @@ -448,10 +482,12 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {boolean} */ hasPreloadedResponse() { return typeof this.response == "object" } + /** @returns {boolean} */ shouldIssueRequest() { if (this.isSamePage) { return false @@ -462,6 +498,7 @@ export class Visit implements FetchRequestDelegate { } } + /** @returns {void} */ cacheSnapshot() { if (!this.snapshotCached) { this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)) @@ -469,22 +506,30 @@ export class Visit implements FetchRequestDelegate { } } - async render(callback: () => Promise) { + /** @param {() => Promise} callback + * @returns {Promise} + */ + async render(callback) { this.cancelRender() - await new Promise((resolve) => { + await new Promise((resolve) => { this.frame = requestAnimationFrame(() => resolve()) }) await callback() delete this.frame } - async renderPageSnapshot(snapshot: PageSnapshot, isPreview: boolean) { + /** @param {PageSnapshot} snapshot + * @param {boolean} isPreview + * @returns {Promise} + */ + async renderPageSnapshot(snapshot, isPreview) { await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { await this.view.renderPage(snapshot, isPreview, this.willRender, this) this.performScroll() }) } + /** @returns {void} */ cancelRender() { if (this.frame) { cancelAnimationFrame(this.frame) @@ -493,6 +538,41 @@ export class Visit implements FetchRequestDelegate { } } -function isSuccessful(statusCode: number) { +/** @param {number} statusCode + * @returns {boolean} + */ +function isSuccessful(statusCode) { return statusCode >= 200 && statusCode < 300 } + +/** @typedef {Partial<{ [metric in TimingMetric]: any }>} TimingMetrics */ +/** + * @typedef {{ + * 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 + * }} VisitOptions + */ +/** + * @typedef {{ + * statusCode: number + * redirected: boolean + * responseHTML?: string + * }} VisitResponse + */ + +/** @typedef {Object} VisitDelegate + * @property {Adapter} adapter + * @property {History} history + * @property {PageView} view + */ diff --git a/src/core/errors.ts b/src/core/errors.js similarity index 70% rename from src/core/errors.ts rename to src/core/errors.js index 1e791f87a..91ffca3dc 100644 --- a/src/core/errors.ts +++ b/src/core/errors.js @@ -1 +1,2 @@ +/** @extends Error */ export class TurboFrameMissingError extends Error {} diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.js similarity index 59% rename from src/core/frames/frame_controller.ts rename to src/core/frames/frame_controller.js index 9282a597c..1e17e99d5 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.js @@ -1,12 +1,7 @@ -import { - FrameElement, - FrameElementDelegate, - FrameLoadingStyle, - FrameElementObservedAttribute, -} from "../../elements/frame_element" -import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request" +import { FrameElement, FrameLoadingStyle } from "../../elements/frame_element" +import { FetchMethod, FetchRequest } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" -import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer" +import { AppearanceObserver } from "../../observers/appearance_observer" import { clearBusyState, dispatch, @@ -17,56 +12,68 @@ import { getHistoryMethodForAction, getVisitAction, } from "../../util" -import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" +import { FormSubmission } from "../drive/form_submission" import { Snapshot } from "../snapshot" -import { ViewDelegate, ViewRenderOptions } from "../view" -import { Locatable, getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" -import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" +import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { FormSubmitObserver } 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 { LinkInterceptor } from "./link_interceptor" +import { FormLinkClickObserver } from "../../observers/form_link_click_observer" import { FrameRenderer } from "./frame_renderer" import { session } from "../index" -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 formLinkClickObserver: FormLinkClickObserver - readonly linkInterceptor: LinkInterceptor - readonly formSubmitObserver: FormSubmitObserver - formSubmission?: FormSubmission - fetchResponseLoaded = (_fetchResponse: FetchResponse) => {} - private currentFetchRequest: FetchRequest | null = null - private resolveVisitPromise = () => {} - private connected = false - private hasBeenLoaded = false - private ignoredAttributes: Set = new Set() - private action: Action | null = null - readonly restorationIdentifier: string - private previousFrameElement?: FrameElement - private currentNavigationElement?: Element - - constructor(element: FrameElement) { +export class FrameController { + /** @readonly */ + element = undefined + /** @readonly */ + view = undefined + /** @readonly */ + appearanceObserver = undefined + /** @readonly */ + formLinkClickObserver = undefined + /** @readonly */ + linkInterceptor = undefined + /** @readonly */ + formSubmitObserver = undefined + /** */ + formSubmission = undefined + /** @default (_fetchResponse: FetchResponse) => {} */ + fetchResponseLoaded = (_fetchResponse) => {} + /** @private + * @default null + */ + currentFetchRequest = null + /** @private + * @default () => {} + */ + resolveVisitPromise = () => {} + /** @private + * @default false + */ + connected = false + /** @private + * @default false + */ + hasBeenLoaded = false + /** @private + * @default new Set() + */ + ignoredAttributes = new Set() + /** @private + * @default null + */ + action = null + /** @readonly */ + restorationIdentifier = undefined + /** @private */ + previousFrameElement = undefined + /** @private */ + currentNavigationElement = undefined + + constructor(element) { this.element = element this.view = new FrameView(this, this.element) this.appearanceObserver = new AppearanceObserver(this, this.element) @@ -76,6 +83,7 @@ export class FrameController this.formSubmitObserver = new FormSubmitObserver(this, this.element) } + /** @returns {void} */ connect() { if (!this.connected) { this.connected = true @@ -90,6 +98,7 @@ export class FrameController } } + /** @returns {void} */ disconnect() { if (this.connected) { this.connected = false @@ -100,12 +109,14 @@ export class FrameController } } + /** @returns {void} */ disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { this.loadSourceURL() } } + /** @returns {void} */ sourceURLChanged() { if (this.isIgnoringChangesTo("src")) return @@ -118,6 +129,7 @@ export class FrameController } } + /** @returns {any} */ sourceURLReloaded() { const { src } = this.element this.ignoringChangesToAttribute("complete", () => { @@ -128,12 +140,14 @@ export class FrameController return this.element.loaded } + /** @returns {void} */ completeChanged() { if (this.isIgnoringChangesTo("complete")) return this.loadSourceURL() } + /** @returns {void} */ loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() @@ -143,7 +157,10 @@ export class FrameController } } - private async loadSourceURL() { + /** @private + * @returns {Promise} + */ + async loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { this.element.loaded = this.visit(expandURL(this.sourceURL)) this.appearanceObserver.stop() @@ -152,7 +169,10 @@ export class FrameController } } - async loadResponse(fetchResponse: FetchResponse) { + /** @param {FetchResponse} fetchResponse + * @returns {Promise} + */ + async loadResponse(fetchResponse) { if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } @@ -176,39 +196,67 @@ export class FrameController // Appearance observer delegate - elementAppearedInViewport(element: FrameElement) { + /** @param {FrameElement} element + * @returns {void} + */ + elementAppearedInViewport(element) { this.proposeVisitIfNavigatedWithAction(element, element) this.loadSourceURL() } // Form link click observer delegate - willSubmitFormLinkToLocation(link: Element): boolean { + /** @param {Element} link + * @returns {boolean} + */ + willSubmitFormLinkToLocation(link) { return this.shouldInterceptNavigation(link) } - submittedFormLinkToLocation(link: Element, _location: URL, form: HTMLFormElement): void { + /** @param {Element} link + * @param {URL} _location + * @param {HTMLFormElement} form + * @returns {void} + */ + submittedFormLinkToLocation(link, _location, form) { const frame = this.findFrameElement(link) if (frame) form.setAttribute("data-turbo-frame", frame.id) } // Link interceptor delegate - shouldInterceptLinkClick(element: Element, _location: string, _event: MouseEvent) { + /** @param {Element} element + * @param {string} _location + * @param {MouseEvent} _event + * @returns {boolean} + */ + shouldInterceptLinkClick(element, _location, _event) { return this.shouldInterceptNavigation(element) } - linkClickIntercepted(element: Element, location: string) { + /** @param {Element} element + * @param {string} location + * @returns {void} + */ + linkClickIntercepted(element, location) { this.navigateFrame(element, location) } // Form submit observer delegate - willSubmitForm(element: HTMLFormElement, submitter?: HTMLElement) { + /** @param {HTMLFormElement} element + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ + willSubmitForm(element, submitter) { return element.closest("turbo-frame") == this.element && this.shouldInterceptNavigation(element, submitter) } - formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { + /** @param {HTMLFormElement} element + * @param {HTMLElement} [submitter] + * @returns {void} + */ + formSubmitted(element, submitter) { if (this.formSubmission) { this.formSubmission.stop() } @@ -221,7 +269,10 @@ export class FrameController // Fetch request delegate - prepareRequest(request: FetchRequest) { + /** @param {FetchRequest} request + * @returns {void} + */ + prepareRequest(request) { request.headers["Turbo-Frame"] = this.id if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { @@ -229,40 +280,69 @@ export class FrameController } } - requestStarted(_request: FetchRequest) { + /** @param {FetchRequest} _request + * @returns {void} + */ + requestStarted(_request) { markAsBusy(this.element) } - requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) { + /** @param {FetchRequest} _request + * @param {FetchResponse} _response + * @returns {void} + */ + requestPreventedHandlingResponse(_request, _response) { this.resolveVisitPromise() } - async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {Promise} + */ + async requestSucceededWithResponse(request, response) { await this.loadResponse(response) this.resolveVisitPromise() } - async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { + /** @param {FetchRequest} request + * @param {FetchResponse} response + * @returns {Promise} + */ + async requestFailedWithResponse(request, response) { await this.loadResponse(response) this.resolveVisitPromise() } - requestErrored(request: FetchRequest, error: Error) { + /** @param {FetchRequest} request + * @param {Error} error + * @returns {void} + */ + requestErrored(request, error) { console.error(error) this.resolveVisitPromise() } - requestFinished(_request: FetchRequest) { + /** @param {FetchRequest} _request + * @returns {void} + */ + requestFinished(_request) { clearBusyState(this.element) } // Form submission delegate - formSubmissionStarted({ formElement }: FormSubmission) { + /** @param {FormSubmission} + * @returns {void} + */ + formSubmissionStarted({ formElement }) { markAsBusy(formElement, this.findFrameElement(formElement)) } - formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) { + /** @param {FormSubmission} formSubmission + * @param {FetchResponse} response + * @returns {void} + */ + formSubmissionSucceededWithResponse(formSubmission, response) { const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter) @@ -273,27 +353,39 @@ export class FrameController } } - formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { + /** @param {FormSubmission} formSubmission + * @param {FetchResponse} fetchResponse + * @returns {void} + */ + formSubmissionFailedWithResponse(formSubmission, fetchResponse) { this.element.delegate.loadResponse(fetchResponse) session.clearCache() } - formSubmissionErrored(formSubmission: FormSubmission, error: Error) { + /** @param {FormSubmission} formSubmission + * @param {Error} error + * @returns {void} + */ + formSubmissionErrored(formSubmission, error) { console.error(error) } - formSubmissionFinished({ formElement }: FormSubmission) { + /** @param {FormSubmission} + * @returns {void} + */ + formSubmissionFinished({ formElement }) { clearBusyState(formElement, this.findFrameElement(formElement)) } // View delegate - allowsImmediateRender( - { element: newFrame }: Snapshot, - _isPreview: boolean, - options: ViewRenderOptions - ) { - const event = dispatch("turbo:before-frame-render", { + /** @param {Snapshot} + * @param {boolean} _isPreview + * @param {ViewRenderOptions} options + * @returns {boolean} + */ + allowsImmediateRender({ element: newFrame }, _isPreview, options) { + const event = dispatch("turbo:before-frame-render", { target: this.element, detail: { newFrame, ...options }, cancelable: true, @@ -310,20 +402,43 @@ export class FrameController return !defaultPrevented } - viewRenderedSnapshot(_snapshot: Snapshot, _isPreview: boolean) {} + /** @param {Snapshot} _snapshot + * @param {boolean} _isPreview + * @returns {void} + */ + viewRenderedSnapshot(_snapshot, _isPreview) {} - preloadOnLoadLinksForView(element: Element) { + /** @param {Element} element + * @returns {void} + */ + preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element) } + /** @returns {void} */ viewInvalidated() {} // Frame renderer delegate - willRenderFrame(currentElement: FrameElement, _newElement: FrameElement) { + /** @param {FrameElement} currentElement + * @param {FrameElement} _newElement + * @returns {void} + */ + willRenderFrame(currentElement, _newElement) { this.previousFrameElement = currentElement.cloneNode(true) } - visitCachedSnapshot = ({ element }: Snapshot) => { + /** + * @default ({ element }: Snapshot) => { + * const frame = element.querySelector("#" + this.element.id) + * // TS-TO-JSDOC BLANK LINE // + * if (frame && this.previousFrameElement) { + * frame.replaceChildren(...this.previousFrameElement.children) + * } + * // TS-TO-JSDOC BLANK LINE // + * delete this.previousFrameElement + * } + */ + visitCachedSnapshot = ({ element }) => { const frame = element.querySelector("#" + this.element.id) if (frame && this.previousFrameElement) { @@ -335,7 +450,12 @@ export class FrameController // Private - private async loadFrameResponse(fetchResponse: FetchResponse, document: Document) { + /** @private + * @param {FetchResponse} fetchResponse + * @param {Document} document + * @returns {Promise} + */ + async loadFrameResponse(fetchResponse, document) { const newFrameElement = await this.extractForeignFrameElement(document.body) if (newFrameElement) { @@ -354,13 +474,17 @@ export class FrameController } } - private async visit(url: URL) { + /** @private + * @param {URL} url + * @returns {Promise} + */ + async visit(url) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) this.currentFetchRequest?.cancel() this.currentFetchRequest = request - return new Promise((resolve) => { + return new Promise((resolve) => { this.resolveVisitPromise = () => { this.resolveVisitPromise = () => {} this.currentFetchRequest = null @@ -370,7 +494,13 @@ export class FrameController }) } - private navigateFrame(element: Element, url: string, submitter?: HTMLElement) { + /** @private + * @param {Element} element + * @param {string} url + * @param {HTMLElement} [submitter] + * @returns {void} + */ + navigateFrame(element, url, submitter) { const frame = this.findFrameElement(element, submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter) @@ -380,19 +510,24 @@ export class FrameController }) } - proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) { + /** @param {FrameElement} frame + * @param {Element} element + * @param {HTMLElement} [submitter] + * @returns {void} + */ + proposeVisitIfNavigatedWithAction(frame, element, submitter) { this.action = getVisitAction(submitter, element, frame) if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone() const { visitCachedSnapshot } = frame.delegate - frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => { + frame.delegate.fetchResponseLoaded = (fetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse const responseHTML = frame.ownerDocument.documentElement.outerHTML const response = { statusCode, redirected, responseHTML } - const options: Partial = { + const options = { response, visitCachedSnapshot, willRender: false, @@ -409,6 +544,7 @@ export class FrameController } } + /** @returns {void} */ changeHistory() { if (this.action) { const method = getHistoryMethodForAction(this.action) @@ -416,7 +552,11 @@ export class FrameController } } - private async handleUnvisitableFrameResponse(fetchResponse: FetchResponse) { + /** @private + * @param {FetchResponse} fetchResponse + * @returns {Promise} + */ + async handleUnvisitableFrameResponse(fetchResponse) { console.warn( `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` ) @@ -424,11 +564,15 @@ export class FrameController await this.visitResponse(fetchResponse.response) } - private willHandleFrameMissingFromResponse(fetchResponse: FetchResponse): boolean { + /** @private + * @param {FetchResponse} fetchResponse + * @returns {boolean} + */ + willHandleFrameMissingFromResponse(fetchResponse) { this.element.setAttribute("complete", "") const response = fetchResponse.response - const visit = async (url: Locatable | Response, options: Partial = {}) => { + const visit = async (url, options = {}) => { if (url instanceof Response) { this.visitResponse(url) } else { @@ -436,7 +580,7 @@ export class FrameController } } - const event = dispatch("turbo:frame-missing", { + const event = dispatch("turbo:frame-missing", { target: this.element, detail: { response, visit }, cancelable: true, @@ -445,17 +589,29 @@ export class FrameController return !event.defaultPrevented } - private handleFrameMissingFromResponse(fetchResponse: FetchResponse) { + /** @private + * @param {FetchResponse} fetchResponse + * @returns {void} + */ + handleFrameMissingFromResponse(fetchResponse) { this.view.missing() this.throwFrameMissingError(fetchResponse) } - private throwFrameMissingError(fetchResponse: FetchResponse) { + /** @private + * @param {FetchResponse} fetchResponse + * @returns {void} + */ + throwFrameMissingError(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 { + /** @private + * @param {Response} response + * @returns {Promise} + */ + async visitResponse(response) { const wrapped = new FetchResponse(response) const responseHTML = await wrapped.responseHTML const { location, redirected, statusCode } = wrapped @@ -463,12 +619,20 @@ export class FrameController return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } - private findFrameElement(element: Element, submitter?: HTMLElement) { + /** @private + * @param {Element} element + * @param {HTMLElement} [submitter] + * @returns {any} + */ + findFrameElement(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") return getFrameElementById(id) ?? this.element } - async extractForeignFrameElement(container: ParentNode): Promise { + /** @param {ParentNode} container + * @returns {Promise} + */ + async extractForeignFrameElement(container) { let element const id = CSS.escape(this.id) @@ -491,13 +655,23 @@ export class FrameController return null } - private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) { + /** @private + * @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {any} + */ + formActionIsVisitable(form, submitter) { const action = getAction(form, submitter) return locationIsVisitable(expandURL(action), this.rootLocation) } - private shouldInterceptNavigation(element: Element, submitter?: HTMLElement) { + /** @private + * @param {Element} element + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ + shouldInterceptNavigation(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") if (element instanceof HTMLFormElement && !this.formActionIsVisitable(element, submitter)) { @@ -542,7 +716,7 @@ export class FrameController } } - set sourceURL(sourceURL: string | undefined) { + set sourceURL(sourceURL) { this.ignoringChangesToAttribute("src", () => { this.element.src = sourceURL ?? null }) @@ -560,7 +734,7 @@ export class FrameController return this.element.hasAttribute("complete") } - set complete(value: boolean) { + set complete(value) { this.ignoringChangesToAttribute("complete", () => { if (value) { this.element.setAttribute("complete", "") @@ -575,29 +749,46 @@ export class FrameController } get rootLocation() { - const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) + const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const root = meta?.content ?? "/" return expandURL(root) } - private isIgnoringChangesTo(attributeName: FrameElementObservedAttribute): boolean { + /** @private + * @param {FrameElementObservedAttribute} attributeName + * @returns {boolean} + */ + isIgnoringChangesTo(attributeName) { return this.ignoredAttributes.has(attributeName) } - private ignoringChangesToAttribute(attributeName: FrameElementObservedAttribute, callback: () => void) { + /** @private + * @param {FrameElementObservedAttribute} attributeName + * @param {() => void} callback + * @returns {void} + */ + ignoringChangesToAttribute(attributeName, callback) { this.ignoredAttributes.add(attributeName) callback() this.ignoredAttributes.delete(attributeName) } - private withCurrentNavigationElement(element: Element, callback: () => void) { + /** @private + * @param {Element} element + * @param {() => void} callback + * @returns {void} + */ + withCurrentNavigationElement(element, callback) { this.currentNavigationElement = element callback() delete this.currentNavigationElement } } -function getFrameElementById(id: string | null) { +/** @param {string | null} id + * @returns {HTMLElement} + */ +function getFrameElementById(id) { if (id != null) { const element = document.getElementById(id) if (element instanceof FrameElement) { @@ -606,7 +797,11 @@ function getFrameElementById(id: string | null) { } } -function activateElement(element: Element | null, currentURL?: string | null) { +/** @param {Element | null} element + * @param {string | null} [currentURL] + * @returns {Element} + */ +function activateElement(element, currentURL) { if (element) { const src = element.getAttribute("src") if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { @@ -623,3 +818,6 @@ function activateElement(element: Element | null, currentURL?: string | null) { } } } + +/** @typedef {(location: Response | Locatable, options: Partial) => Promise} VisitFallback */ +/** @typedef {CustomEvent<{ response: Response; visit: VisitFallback }>} TurboFrameMissingEvent */ diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.js similarity index 53% rename from src/core/frames/frame_redirector.ts rename to src/core/frames/frame_redirector.js index 3175e9fe0..e4f7636c1 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.js @@ -1,43 +1,63 @@ -import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" +import { FormSubmitObserver } from "../../observers/form_submit_observer" import { FrameElement } from "../../elements/frame_element" -import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" +import { LinkInterceptor } from "./link_interceptor" 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 formSubmitObserver: FormSubmitObserver - constructor(session: Session, element: Element) { +export class FrameRedirector { + /** @readonly */ + session = undefined + /** @readonly */ + element = undefined + /** @readonly */ + linkInterceptor = undefined + /** @readonly */ + formSubmitObserver = undefined + + constructor(session, element) { this.session = session this.element = element this.linkInterceptor = new LinkInterceptor(this, element) this.formSubmitObserver = new FormSubmitObserver(this, element) } + /** @returns {void} */ start() { this.linkInterceptor.start() this.formSubmitObserver.start() } + /** @returns {void} */ stop() { this.linkInterceptor.stop() this.formSubmitObserver.stop() } - shouldInterceptLinkClick(element: Element, _location: string, _event: MouseEvent) { + /** @param {Element} element + * @param {string} _location + * @param {MouseEvent} _event + * @returns {boolean} + */ + shouldInterceptLinkClick(element, _location, _event) { return this.shouldRedirect(element) } - linkClickIntercepted(element: Element, url: string, event: MouseEvent) { + /** @param {Element} element + * @param {string} url + * @param {MouseEvent} event + * @returns {void} + */ + linkClickIntercepted(element, url, event) { const frame = this.findFrameElement(element) if (frame) { frame.delegate.linkClickIntercepted(element, url, event) } } - willSubmitForm(element: HTMLFormElement, submitter?: HTMLElement) { + /** @param {HTMLFormElement} element + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ + willSubmitForm(element, submitter) { return ( element.closest("turbo-frame") == null && this.shouldSubmit(element, submitter) && @@ -45,22 +65,36 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObser ) } - formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { + /** @param {HTMLFormElement} element + * @param {HTMLElement} [submitter] + * @returns {void} + */ + formSubmitted(element, submitter) { const frame = this.findFrameElement(element, submitter) if (frame) { frame.delegate.formSubmitted(element, submitter) } } - private shouldSubmit(form: HTMLFormElement, submitter?: HTMLElement) { + /** @private + * @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {any} + */ + shouldSubmit(form, submitter) { const action = getAction(form, submitter) - const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) + 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) { + /** @private + * @param {Element} element + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ + shouldRedirect(element, submitter) { const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter) @@ -74,7 +108,12 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObser } } - private findFrameElement(element: Element, submitter?: HTMLElement) { + /** @private + * @param {Element} element + * @param {HTMLElement} [submitter] + * @returns {Element} + */ + findFrameElement(element, submitter) { const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame") if (id && id != "_top") { const frame = this.element.querySelector(`#${id}:not([disabled])`) diff --git a/src/core/frames/frame_renderer.ts b/src/core/frames/frame_renderer.js similarity index 69% rename from src/core/frames/frame_renderer.ts rename to src/core/frames/frame_renderer.js index 4f9a17253..bad38f5cc 100644 --- a/src/core/frames/frame_renderer.ts +++ b/src/core/frames/frame_renderer.js @@ -1,16 +1,19 @@ -import { FrameElement } from "../../elements/frame_element" import { activateScriptElement, nextAnimationFrame } from "../../util" -import { Render, Renderer } from "../renderer" -import { Snapshot } from "../snapshot" +import { Renderer } from "../renderer" -export interface FrameRendererDelegate { - willRenderFrame(currentElement: FrameElement, newElement: FrameElement): void -} - -export class FrameRenderer extends Renderer { - private readonly delegate: FrameRendererDelegate +/** @extends Renderer */ +export class FrameRenderer extends Renderer { + /** @private + * @readonly + */ + delegate = undefined - static renderElement(currentElement: FrameElement, newElement: FrameElement) { + /** @static + * @param {FrameElement} currentElement + * @param {FrameElement} newElement + * @returns {void} + */ + static renderElement(currentElement, newElement) { const destinationRange = document.createRange() destinationRange.selectNodeContents(currentElement) destinationRange.deleteContents() @@ -23,14 +26,7 @@ export class FrameRenderer extends Renderer { } } - constructor( - delegate: FrameRendererDelegate, - currentSnapshot: Snapshot, - newSnapshot: Snapshot, - renderElement: Render, - isPreview: boolean, - willRender = true - ) { + constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate } @@ -39,6 +35,7 @@ export class FrameRenderer extends Renderer { return true } + /** @returns {Promise} */ async render() { await nextAnimationFrame() this.preservingPermanentElements(() => { @@ -51,11 +48,13 @@ export class FrameRenderer extends Renderer { this.activateScriptElements() } + /** @returns {void} */ loadFrameElement() { this.delegate.willRenderFrame(this.currentElement, this.newElement) this.renderElement(this.currentElement, this.newElement) } + /** @returns {boolean} */ scrollFrameIntoView() { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild @@ -70,6 +69,7 @@ export class FrameRenderer extends Renderer { return false } + /** @returns {void} */ activateScriptElements() { for (const inertScriptElement of this.newScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement) @@ -82,7 +82,11 @@ export class FrameRenderer extends Renderer { } } -function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLogicalPosition): ScrollLogicalPosition { +/** @param {string | null} value + * @param {ScrollLogicalPosition} defaultValue + * @returns {ScrollLogicalPosition} + */ +function readScrollLogicalPosition(value, defaultValue) { if (value == "end" || value == "start" || value == "center" || value == "nearest") { return value } else { @@ -90,10 +94,16 @@ function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLog } } -function readScrollBehavior(value: string | null, defaultValue: ScrollBehavior): ScrollBehavior { +/** @param {string | null} value + * @param {ScrollBehavior} defaultValue + * @returns {ScrollBehavior} + */ +function readScrollBehavior(value, defaultValue) { if (value == "auto" || value == "smooth") { return value } else { return defaultValue } } + +/** @typedef {Object} FrameRendererDelegate */ diff --git a/src/core/frames/frame_view.js b/src/core/frames/frame_view.js new file mode 100644 index 000000000..24a5c7026 --- /dev/null +++ b/src/core/frames/frame_view.js @@ -0,0 +1,16 @@ +import { Snapshot } from "../snapshot" +import { View } from "../view" + +/** @extends View */ +export class FrameView extends View { + /** @returns {void} */ + missing() { + this.element.innerHTML = `Content missing` + } + + get snapshot() { + return new Snapshot(this.element) + } +} + +/** @typedef {ViewRenderOptions} FrameViewRenderOptions */ diff --git a/src/core/frames/frame_view.ts b/src/core/frames/frame_view.ts deleted file mode 100644 index b19714f35..000000000 --- a/src/core/frames/frame_view.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FrameElement } from "../../elements" -import { Snapshot } from "../snapshot" -import { View, ViewRenderOptions } from "../view" - -export type FrameViewRenderOptions = ViewRenderOptions - -export class FrameView extends View { - missing() { - this.element.innerHTML = `Content missing` - } - - get snapshot() { - return new Snapshot(this.element) - } -} diff --git a/src/core/frames/link_interceptor.js b/src/core/frames/link_interceptor.js new file mode 100644 index 000000000..272de154d --- /dev/null +++ b/src/core/frames/link_interceptor.js @@ -0,0 +1,86 @@ +export class LinkInterceptor { + /** @readonly */ + delegate = undefined + /** @readonly */ + element = undefined + /** @private */ + clickEvent = undefined + + constructor(delegate, element) { + this.delegate = delegate + this.element = element + } + + /** @returns {void} */ + start() { + this.element.addEventListener("click", this.clickBubbled) + document.addEventListener("turbo:click", this.linkClicked) + document.addEventListener("turbo:before-visit", this.willVisit) + } + + /** @returns {void} */ + stop() { + this.element.removeEventListener("click", this.clickBubbled) + document.removeEventListener("turbo:click", this.linkClicked) + document.removeEventListener("turbo:before-visit", this.willVisit) + } + + /** + * @default (event: Event) => { + * if (this.respondsToEventTarget(event.target)) { + * this.clickEvent = event + * } else { + * delete this.clickEvent + * } + * } + */ + clickBubbled = (event) => { + if (this.respondsToEventTarget(event.target)) { + this.clickEvent = event + } else { + delete this.clickEvent + } + } + + /** + * @default ((event: TurboClickEvent) => { + * if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { + * 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, event.detail.originalEvent) + * } + * } + * delete this.clickEvent + * }) + */ + linkClicked = (event) => { + if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { + 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, event.detail.originalEvent) + } + } + delete this.clickEvent + } + + /** + * @default ((_event: TurboBeforeVisitEvent) => { + * delete this.clickEvent + * }) + */ + willVisit = (_event) => { + delete this.clickEvent + } + + /** @param {EventTarget | null} target + * @returns {boolean} + */ + respondsToEventTarget(target) { + const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null + return element && element.closest("turbo-frame, html") == this.element + } +} + +/** @typedef {Object} LinkInterceptorDelegate */ diff --git a/src/core/frames/link_interceptor.ts b/src/core/frames/link_interceptor.ts deleted file mode 100644 index 8f2e13f40..000000000 --- a/src/core/frames/link_interceptor.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TurboClickEvent, TurboBeforeVisitEvent } from "../session" - -export interface LinkInterceptorDelegate { - shouldInterceptLinkClick(element: Element, url: string, originalEvent: MouseEvent): boolean - linkClickIntercepted(element: Element, url: string, originalEvent: MouseEvent): void -} - -export class LinkInterceptor { - readonly delegate: LinkInterceptorDelegate - readonly element: Element - private clickEvent?: Event - - constructor(delegate: LinkInterceptorDelegate, element: Element) { - this.delegate = delegate - this.element = element - } - - start() { - this.element.addEventListener("click", this.clickBubbled) - document.addEventListener("turbo:click", this.linkClicked) - document.addEventListener("turbo:before-visit", this.willVisit) - } - - stop() { - this.element.removeEventListener("click", this.clickBubbled) - document.removeEventListener("turbo:click", this.linkClicked) - document.removeEventListener("turbo:before-visit", this.willVisit) - } - - clickBubbled = (event: Event) => { - if (this.respondsToEventTarget(event.target)) { - this.clickEvent = event - } else { - delete this.clickEvent - } - } - - linkClicked = ((event: TurboClickEvent) => { - if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { - 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, event.detail.originalEvent) - } - } - delete this.clickEvent - }) - - willVisit = ((_event: TurboBeforeVisitEvent) => { - delete this.clickEvent - }) - - respondsToEventTarget(target: EventTarget | 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/src/core/index.js b/src/core/index.js new file mode 100644 index 000000000..1bce703a8 --- /dev/null +++ b/src/core/index.js @@ -0,0 +1,112 @@ +import { Session } from "./session" +import { Cache } from "./cache" +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 cache = new Cache(session) +const { navigator } = session +export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer } + +export { StreamActions } from "./streams/stream_actions" + +/** + * Starts the main session. + * This initialises any necessary observers such as those to monitor + * link interactions. + * @returns {void} + */ +export function start() { + session.start() +} + +/** + * Registers an adapter for the main session. + * @param {Adapter} adapter Adapter to register + * @returns {void} + */ +export function registerAdapter(adapter) { + session.registerAdapter(adapter) +} + +/** + * Performs an application visit to the given location. + * @param {Locatable} location Location to visit (a URL or path) + * @param {Partial} [options] Options to apply + * @returns {void} + */ +export function visit(location, options) { + session.visit(location, options) +} + +/** + * Connects a stream source to the main session. + * @param {StreamSource} source Stream source to connect + * @returns {void} + */ +export function connectStreamSource(source) { + session.connectStreamSource(source) +} + +/** + * Disconnects a stream source from the main session. + * @param {StreamSource} source Stream source to disconnect + * @returns {void} + */ +export function disconnectStreamSource(source) { + session.disconnectStreamSource(source) +} + +/** + * Renders a stream message to the main session by appending it to the + * current document. + * @param {StreamMessage | string} message Message to render + * @returns {void} + */ +export function renderStreamMessage(message) { + session.renderStreamMessage(message) +} + +/** + * 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()` + * @returns {void} + */ +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() +} + +/** + * Sets the delay after which the progress bar will appear during navigation. + * + * The progress bar appears after 500ms by default. + * + * Note that this method has no effect when used with the iOS or Android + * adapters. + * @param {number} delay Time to delay in milliseconds + * @returns {void} + */ +export function setProgressBarDelay(delay) { + session.setProgressBarDelay(delay) +} + +/** @param {(message: string, element: HTMLFormElement, submitter: HTMLElement | undefined) => Promise} confirmMethod + * @returns {void} + */ +export function setConfirmMethod(confirmMethod) { + FormSubmission.confirmMethod = confirmMethod +} + +/** @param {FormMode} mode + * @returns {void} + */ +export function setFormMode(mode) { + session.setFormMode(mode) +} diff --git a/src/core/index.ts b/src/core/index.ts deleted file mode 100644 index 7b7507c3f..000000000 --- a/src/core/index.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Adapter } from "./native/adapter" -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 cache = new Cache(session) -const { navigator } = session -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. - * This initialises any necessary observers such as those to monitor - * link interactions. - */ -export function start() { - session.start() -} - -/** - * Registers an adapter for the main session. - * - * @param adapter Adapter to register - */ -export function registerAdapter(adapter: Adapter) { - session.registerAdapter(adapter) -} - -/** - * Performs an application visit to the given location. - * - * @param location Location to visit (a URL or path) - * @param options Options to apply - * @param options.action Type of history navigation to apply ("restore", - * "replace" or "advance") - * @param options.historyChanged Specifies whether the browser history has - * already been changed for this visit or not - * @param options.referrer Specifies the referrer of this visit such that - * navigations to the same page will not result in a new history entry. - * @param options.snapshotHTML Cached snapshot to render - * @param options.response Response of the specified location - */ -export function visit(location: Locatable, options?: Partial) { - session.visit(location, options) -} - -/** - * Connects a stream source to the main session. - * - * @param source Stream source to connect - */ -export function connectStreamSource(source: StreamSource) { - session.connectStreamSource(source) -} - -/** - * Disconnects a stream source from the main session. - * - * @param source Stream source to disconnect - */ -export function disconnectStreamSource(source: StreamSource) { - session.disconnectStreamSource(source) -} - -/** - * Renders a stream message to the main session by appending it to the - * current document. - * - * @param message Message to render - */ -export function renderStreamMessage(message: StreamMessage | string) { - session.renderStreamMessage(message) -} - -/** - * 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() -} - -/** - * Sets the delay after which the progress bar will appear during navigation. - * - * The progress bar appears after 500ms by default. - * - * Note that this method has no effect when used with the iOS or Android - * adapters. - * - * @param delay Time to delay in milliseconds - */ -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/src/core/native/adapter.js b/src/core/native/adapter.js new file mode 100644 index 000000000..28edb3470 --- /dev/null +++ b/src/core/native/adapter.js @@ -0,0 +1,3 @@ +export {} + +/** @typedef {Object} Adapter */ diff --git a/src/core/native/adapter.ts b/src/core/native/adapter.ts deleted file mode 100644 index 3ecfd9b22..000000000 --- a/src/core/native/adapter.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 - visitStarted(visit: Visit): void - visitCompleted(visit: Visit): void - visitFailed(visit: Visit): void - visitRequestStarted(visit: Visit): void - visitRequestCompleted(visit: Visit): void - visitRequestFailedWithStatusCode(visit: Visit, statusCode: number): void - visitRequestFinished(visit: Visit): void - visitRendered(visit: Visit): void - formSubmissionStarted?(formSubmission: FormSubmission): void - formSubmissionFinished?(formSubmission: FormSubmission): void - pageInvalidated(reason: ReloadReason): void -} diff --git a/src/core/native/browser_adapter.ts b/src/core/native/browser_adapter.js similarity index 52% rename from src/core/native/browser_adapter.ts rename to src/core/native/browser_adapter.js index ca268e341..cd69d68f7 100644 --- a/src/core/native/browser_adapter.ts +++ b/src/core/native/browser_adapter.js @@ -1,40 +1,48 @@ -import { Adapter } from "./adapter" import { ProgressBar } from "../drive/progress_bar" -import { SystemStatusCode, Visit, VisitOptions } from "../drive/visit" -import { FormSubmission } from "../drive/form_submission" -import { Session } from "../session" +import { SystemStatusCode } from "../drive/visit" 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() - - visitProgressBarTimeout?: number - formProgressBarTimeout?: number - location?: URL - - constructor(session: Session) { +export class BrowserAdapter { + /** @readonly */ + session = undefined + /** @readonly + * @default new ProgressBar() + */ + progressBar = new ProgressBar() + + /** */ + visitProgressBarTimeout = undefined + /** */ + formProgressBarTimeout = undefined + /** */ + location = undefined + + constructor(session) { this.session = session } - visitProposedToLocation(location: URL, options?: Partial) { + /** @param {URL} location + * @param {Partial} [options] + * @returns {void} + */ + visitProposedToLocation(location, options) { this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options) } - visitStarted(visit: Visit) { + /** @param {Visit} visit + * @returns {void} + */ + visitStarted(visit) { this.location = visit.location visit.loadCachedSnapshot() visit.issueRequest() visit.goToSamePageAnchor() } - visitRequestStarted(visit: Visit) { + /** @param {Visit} visit + * @returns {void} + */ + visitRequestStarted(visit) { this.progressBar.setValue(0) if (visit.hasCachedSnapshot() || visit.action != "restore") { this.showVisitProgressBarAfterDelay() @@ -43,11 +51,18 @@ export class BrowserAdapter implements Adapter { } } - visitRequestCompleted(visit: Visit) { + /** @param {Visit} visit + * @returns {void} + */ + visitRequestCompleted(visit) { visit.loadResponse() } - visitRequestFailedWithStatusCode(visit: Visit, statusCode: number) { + /** @param {Visit} visit + * @param {number} statusCode + * @returns {any} + */ + visitRequestFailedWithStatusCode(visit, statusCode) { switch (statusCode) { case SystemStatusCode.networkFailure: case SystemStatusCode.timeoutFailure: @@ -63,37 +78,60 @@ export class BrowserAdapter implements Adapter { } } - visitRequestFinished(_visit: Visit) { + /** @param {Visit} _visit + * @returns {void} + */ + visitRequestFinished(_visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } - visitCompleted(_visit: Visit) {} + /** @param {Visit} _visit + * @returns {void} + */ + visitCompleted(_visit) {} - pageInvalidated(reason: ReloadReason) { + /** @param {ReloadReason} reason + * @returns {void} + */ + pageInvalidated(reason) { this.reload(reason) } - visitFailed(_visit: Visit) {} + /** @param {Visit} _visit + * @returns {void} + */ + visitFailed(_visit) {} - visitRendered(_visit: Visit) {} + /** @param {Visit} _visit + * @returns {void} + */ + visitRendered(_visit) {} - formSubmissionStarted(_formSubmission: FormSubmission) { + /** @param {FormSubmission} _formSubmission + * @returns {void} + */ + formSubmissionStarted(_formSubmission) { this.progressBar.setValue(0) this.showFormProgressBarAfterDelay() } - formSubmissionFinished(_formSubmission: FormSubmission) { + /** @param {FormSubmission} _formSubmission + * @returns {void} + */ + formSubmissionFinished(_formSubmission) { this.progressBar.setValue(1) this.hideFormProgressBar() } // Private + /** @returns {void} */ showVisitProgressBarAfterDelay() { this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay) } + /** @returns {void} */ hideVisitProgressBar() { this.progressBar.hide() if (this.visitProgressBarTimeout != null) { @@ -102,12 +140,14 @@ export class BrowserAdapter implements Adapter { } } + /** @returns {void} */ showFormProgressBarAfterDelay() { if (this.formProgressBarTimeout == null) { this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay) } } + /** @returns {void} */ hideFormProgressBar() { this.progressBar.hide() if (this.formProgressBarTimeout != null) { @@ -116,11 +156,19 @@ export class BrowserAdapter implements Adapter { } } + /** + * @default () => { + * this.progressBar.show() + * } + */ showProgressBar = () => { this.progressBar.show() } - reload(reason: ReloadReason) { + /** @param {ReloadReason} reason + * @returns {void} + */ + reload(reason) { dispatch("turbo:reload", { detail: reason }) window.location.href = this.location?.toString() || window.location.href @@ -130,3 +178,10 @@ export class BrowserAdapter implements Adapter { return this.session.navigator } } + +/** @typedef {StructuredReason | undefined} ReloadReason */ + +/** @typedef {Object} StructuredReason + * @property {string} reason + * @property {{[key:string]:any}} [context] + */ diff --git a/src/core/renderer.ts b/src/core/renderer.js similarity index 55% rename from src/core/renderer.ts rename to src/core/renderer.js index e77be9d34..84756f4b9 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.js @@ -1,25 +1,26 @@ -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 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, renderElement: Render, isPreview: boolean, willRender = true) { +import { Bardo } from "./bardo" + +export class Renderer { + /** @readonly */ + currentSnapshot = undefined + /** @readonly */ + newSnapshot = undefined + /** @readonly */ + isPreview = undefined + /** @readonly */ + willRender = undefined + /** @readonly */ + promise = undefined + /** */ + renderElement = undefined + /** @private */ + resolvingFunctions = undefined + /** @private + * @default null + */ + activeElement = null + + constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview @@ -32,16 +33,16 @@ export abstract class Renderer = Snapsh return true } - get reloadReason(): ReloadReason { + get reloadReason() { return } + /** @returns {void} */ prepareToRender() { return } - abstract render(): Promise - + /** @returns {void} */ finishRendering() { if (this.resolvingFunctions) { this.resolvingFunctions.resolve() @@ -49,10 +50,14 @@ export abstract class Renderer = Snapsh } } - async preservingPermanentElements(callback: () => void) { + /** @param {() => void} callback + * @returns {Promise} + */ + async preservingPermanentElements(callback) { await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback) } + /** @returns {void} */ focusFirstAutofocusableElement() { const element = this.connectedSnapshot.firstAutofocusableElement if (elementIsFocusable(element)) { @@ -62,7 +67,10 @@ export abstract class Renderer = Snapsh // Bardo delegate - enteringBardo(currentPermanentElement: Element) { + /** @param {Element} currentPermanentElement + * @returns {void} + */ + enteringBardo(currentPermanentElement) { if (this.activeElement) return if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { @@ -70,7 +78,10 @@ export abstract class Renderer = Snapsh } } - leavingBardo(currentPermanentElement: Element) { + /** @param {Element} currentPermanentElement + * @returns {void} + */ + leavingBardo(currentPermanentElement) { if (currentPermanentElement.contains(this.activeElement) && this.activeElement instanceof HTMLElement) { this.activeElement.focus() @@ -95,6 +106,21 @@ export abstract class Renderer = Snapsh } } -function elementIsFocusable(element: any): element is { focus: () => void } { +/** @param {any} element + * @returns {element is { focus: () => void }} + */ +function elementIsFocusable(element) { return element && typeof element.focus == "function" } + +/** + * @typedef {{ + * resolve(value: T | PromiseLike): void + * reject(reason?: any): void + * }} ResolvingFunctions + * @template [T=unknown] + */ +/** + * @typedef {(currentElement: E, newElement: E) => void} Render + * @template E + */ diff --git a/src/core/session.js b/src/core/session.js new file mode 100644 index 000000000..2f0355309 --- /dev/null +++ b/src/core/session.js @@ -0,0 +1,654 @@ +import { BrowserAdapter } from "./native/browser_adapter" +import { CacheObserver } from "../observers/cache_observer" +import { FormSubmitObserver } from "../observers/form_submit_observer" +import { FrameRedirector } from "./frames/frame_redirector" +import { History } from "./drive/history" +import { LinkClickObserver } from "../observers/link_click_observer" +import { FormLinkClickObserver } from "../observers/form_link_click_observer" +import { getAction, expandURL, locationIsVisitable } from "./url" +import { Navigator } from "./drive/navigator" +import { PageObserver } 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 { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markAsBusy } from "../util" +import { PageView } from "./drive/page_view" +import { FrameElement } from "../elements/frame_element" +import { Preloader } from "./drive/preloader" + +export class Session { + /** @readonly + * @default new Navigator(this) + */ + navigator = new Navigator(this) + /** @readonly + * @default new History(this) + */ + history = new History(this) + /** @readonly + * @default new Preloader(this) + */ + preloader = new Preloader(this) + /** @readonly + * @default new PageView(this, document.documentElement as HTMLBodyElement) + */ + view = new PageView(this, document.documentElement) + /** @default new BrowserAdapter(this) */ + adapter = new BrowserAdapter(this) + + /** @readonly + * @default new PageObserver(this) + */ + pageObserver = new PageObserver(this) + /** @readonly + * @default new CacheObserver() + */ + cacheObserver = new CacheObserver() + /** @readonly + * @default new LinkClickObserver(this, window) + */ + linkClickObserver = new LinkClickObserver(this, window) + /** @readonly + * @default new FormSubmitObserver(this, document) + */ + formSubmitObserver = new FormSubmitObserver(this, document) + /** @readonly + * @default new ScrollObserver(this) + */ + scrollObserver = new ScrollObserver(this) + /** @readonly + * @default new StreamObserver(this) + */ + streamObserver = new StreamObserver(this) + /** @readonly + * @default new FormLinkClickObserver(this, document.documentElement) + */ + formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) + /** @readonly + * @default new FrameRedirector(this, document.documentElement) + */ + frameRedirector = new FrameRedirector(this, document.documentElement) + /** @readonly + * @default new StreamMessageRenderer() + */ + streamMessageRenderer = new StreamMessageRenderer() + + /** @default true */ + drive = true + /** @default true */ + enabled = true + /** @default 500 */ + progressBarDelay = 500 + /** @default false */ + started = false + /** @default "on" */ + formMode = "on" + + /** @returns {void} */ + 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 + } + } + + /** @returns {void} */ + disable() { + this.enabled = false + } + + /** @returns {void} */ + stop() { + if (this.started) { + this.pageObserver.stop() + this.cacheObserver.stop() + this.formLinkClickObserver.stop() + this.linkClickObserver.stop() + this.formSubmitObserver.stop() + this.scrollObserver.stop() + this.streamObserver.stop() + this.frameRedirector.stop() + this.history.stop() + this.started = false + } + } + + /** @param {Adapter} adapter + * @returns {void} + */ + registerAdapter(adapter) { + this.adapter = adapter + } + + /** @param {Locatable} location + * @param {Partial} [options={}] + * @returns {void} + */ + visit(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) + } + } + + /** @param {StreamSource} source + * @returns {void} + */ + connectStreamSource(source) { + this.streamObserver.connectStreamSource(source) + } + + /** @param {StreamSource} source + * @returns {void} + */ + disconnectStreamSource(source) { + this.streamObserver.disconnectStreamSource(source) + } + + /** @param {StreamMessage | string} message + * @returns {void} + */ + renderStreamMessage(message) { + this.streamMessageRenderer.render(StreamMessage.wrap(message)) + } + + /** @returns {void} */ + clearCache() { + this.view.clearSnapshotCache() + } + + /** @param {number} delay + * @returns {void} + */ + setProgressBarDelay(delay) { + this.progressBarDelay = delay + } + + /** @param {FormMode} mode + * @returns {void} + */ + setFormMode(mode) { + this.formMode = mode + } + + get location() { + return this.history.location + } + + get restorationIdentifier() { + return this.history.restorationIdentifier + } + + // History delegate + + /** @param {URL} location + * @param {string} restorationIdentifier + * @returns {void} + */ + historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier) { + if (this.enabled) { + this.navigator.startVisit(location, restorationIdentifier, { + action: "restore", + historyChanged: true, + }) + } else { + this.adapter.pageInvalidated({ + reason: "turbo_disabled", + }) + } + } + + // Scroll observer delegate + + /** @param {Position} position + * @returns {void} + */ + scrollPositionChanged(position) { + this.history.updateRestorationData({ scrollPosition: position }) + } + + // Form click observer delegate + + /** @param {Element} link + * @param {URL} location + * @returns {boolean} + */ + willSubmitFormLinkToLocation(link, location) { + return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) + } + + /** @returns {void} */ + submittedFormLinkToLocation() {} + + // Link click observer delegate + + /** @param {Element} link + * @param {URL} location + * @param {MouseEvent} event + * @returns {boolean} + */ + willFollowLinkToLocation(link, location, event) { + return ( + this.elementIsNavigatable(link) && + locationIsVisitable(location, this.snapshot.rootLocation) && + this.applicationAllowsFollowingLinkToLocation(link, location, event) + ) + } + + /** @param {Element} link + * @param {URL} location + * @returns {void} + */ + followedLinkToLocation(link, location) { + const action = this.getActionForLink(link) + const acceptsStreamResponse = link.hasAttribute("data-turbo-stream") + + this.visit(location.href, { action, acceptsStreamResponse }) + } + + // Navigator delegate + + /** @param {URL} location + * @param {Action} [action] + * @returns {boolean} + */ + allowsVisitingLocationWithAction(location, action) { + return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) + } + + /** @param {URL} location + * @param {Partial} options + * @returns {void} + */ + visitProposedToLocation(location, options) { + extendURLWithDeprecatedProperties(location) + this.adapter.visitProposedToLocation(location, options) + } + + /** @param {Visit} visit + * @returns {void} + */ + visitStarted(visit) { + if (!visit.acceptsStreamResponse) { + markAsBusy(document.documentElement) + } + extendURLWithDeprecatedProperties(visit.location) + if (!visit.silent) { + this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) + } + } + + /** @param {Visit} visit + * @returns {void} + */ + visitCompleted(visit) { + clearBusyState(document.documentElement) + this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) + } + + /** @param {URL} location + * @param {Action} [action] + * @returns {boolean} + */ + locationWithActionIsSamePage(location, action) { + return this.navigator.locationWithActionIsSamePage(location, action) + } + + /** @param {URL} oldURL + * @param {URL} newURL + * @returns {void} + */ + visitScrolledToSamePageLocation(oldURL, newURL) { + this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) + } + + // Form submit observer delegate + + /** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ + willSubmitForm(form, submitter) { + const action = getAction(form, submitter) + + return ( + this.submissionIsNavigatable(form, submitter) && + locationIsVisitable(expandURL(action), this.snapshot.rootLocation) + ) + } + + /** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {void} + */ + formSubmitted(form, submitter) { + this.navigator.submitForm(form, submitter) + } + + // Page observer delegate + + /** @returns {void} */ + pageBecameInteractive() { + this.view.lastRenderedLocation = this.location + this.notifyApplicationAfterPageLoad() + } + + /** @returns {void} */ + pageLoaded() { + this.history.assumeControlOfScrollRestoration() + } + + /** @returns {void} */ + pageWillUnload() { + this.history.relinquishControlOfScrollRestoration() + } + + // Stream observer delegate + + /** @param {StreamMessage} message + * @returns {void} + */ + receivedMessageFromStream(message) { + this.renderStreamMessage(message) + } + + // Page view delegate + + /** @returns {void} */ + viewWillCacheSnapshot() { + if (!this.navigator.currentVisit?.silent) { + this.notifyApplicationBeforeCachingSnapshot() + } + } + + /** @param {PageSnapshot} + * @param {boolean} isPreview + * @param {PageViewRenderOptions} options + * @returns {boolean} + */ + allowsImmediateRender({ element }, isPreview, options) { + const event = this.notifyApplicationBeforeRender(element, isPreview, options) + const { + defaultPrevented, + detail: { render }, + } = event + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render + } + + return !defaultPrevented + } + + /** @param {PageSnapshot} _snapshot + * @param {boolean} isPreview + * @returns {void} + */ + viewRenderedSnapshot(_snapshot, isPreview) { + this.view.lastRenderedLocation = this.history.location + this.notifyApplicationAfterRender(isPreview) + } + + /** @param {Element} element + * @returns {void} + */ + preloadOnLoadLinksForView(element) { + this.preloader.preloadOnLoadLinksForView(element) + } + + /** @param {ReloadReason} reason + * @returns {void} + */ + viewInvalidated(reason) { + this.adapter.pageInvalidated(reason) + } + + // Frame element + + /** @param {FrameElement} frame + * @returns {void} + */ + frameLoaded(frame) { + this.notifyApplicationAfterFrameLoad(frame) + } + + /** @param {FetchResponse} fetchResponse + * @param {FrameElement} frame + * @returns {void} + */ + frameRendered(fetchResponse, frame) { + this.notifyApplicationAfterFrameRender(fetchResponse, frame) + } + + // Application events + + /** @param {Element} link + * @param {URL} location + * @param {MouseEvent} ev + * @returns {boolean} + */ + applicationAllowsFollowingLinkToLocation(link, location, ev) { + const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) + return !event.defaultPrevented + } + + /** @param {URL} location + * @returns {boolean} + */ + applicationAllowsVisitingLocation(location) { + const event = this.notifyApplicationBeforeVisitingLocation(location) + return !event.defaultPrevented + } + + /** @param {Element} link + * @param {URL} location + * @param {MouseEvent} event + * @returns {any} + */ + notifyApplicationAfterClickingLinkToLocation(link, location, event) { + return dispatch("turbo:click", { + target: link, + detail: { url: location.href, originalEvent: event }, + cancelable: true, + }) + } + + /** @param {URL} location + * @returns {any} + */ + notifyApplicationBeforeVisitingLocation(location) { + return dispatch("turbo:before-visit", { + detail: { url: location.href }, + cancelable: true, + }) + } + + /** @param {URL} location + * @param {Action} action + * @returns {any} + */ + notifyApplicationAfterVisitingLocation(location, action) { + return dispatch("turbo:visit", { detail: { url: location.href, action } }) + } + + /** @returns {any} */ + notifyApplicationBeforeCachingSnapshot() { + return dispatch("turbo:before-cache") + } + + /** @param {HTMLBodyElement} newBody + * @param {boolean} isPreview + * @param {PageViewRenderOptions} options + * @returns {any} + */ + notifyApplicationBeforeRender(newBody, isPreview, options) { + return dispatch("turbo:before-render", { + detail: { newBody, isPreview, ...options }, + cancelable: true, + }) + } + + /** @param {boolean} isPreview + * @returns {any} + */ + notifyApplicationAfterRender(isPreview) { + return dispatch("turbo:render", { detail: { isPreview } }) + } + + /** @param {TimingData} [timing={}] + * @returns {any} + */ + notifyApplicationAfterPageLoad(timing = {}) { + return dispatch("turbo:load", { + detail: { url: this.location.href, timing }, + }) + } + + /** @param {URL} oldURL + * @param {URL} newURL + * @returns {void} + */ + notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { + dispatchEvent( + new HashChangeEvent("hashchange", { + oldURL: oldURL.toString(), + newURL: newURL.toString(), + }) + ) + } + + /** @param {FrameElement} frame + * @returns {any} + */ + notifyApplicationAfterFrameLoad(frame) { + return dispatch("turbo:frame-load", { target: frame }) + } + + /** @param {FetchResponse} fetchResponse + * @param {FrameElement} frame + * @returns {any} + */ + notifyApplicationAfterFrameRender(fetchResponse, frame) { + return dispatch("turbo:frame-render", { + detail: { fetchResponse }, + target: frame, + cancelable: true, + }) + } + + // Helpers + + /** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ + submissionIsNavigatable(form, submitter) { + if (this.formMode == "off") { + return false + } else { + const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true + + if (this.formMode == "optin") { + return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null + } else { + return submitterIsNavigatable && this.elementIsNavigatable(form) + } + } + } + + /** @param {Element} element + * @returns {boolean} + */ + elementIsNavigatable(element) { + 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 { + // Element isn't navigatable by default, unless `data-turbo="true"`. + if (container) { + return container.getAttribute("data-turbo") == "true" + } else { + return false + } + } + } + + // Private + + /** @param {Element} link + * @returns {Action} + */ + getActionForLink(link) { + return getVisitAction(link) || "advance" + } + + get snapshot() { + return this.view.snapshot + } +} + +// Older versions of the Turbo Native adapters referenced the +// `Location#absoluteURL` property in their implementations of +// the `Adapter#visitProposedToLocation()` and `#visitStarted()` +// methods. The Location class has since been removed in favor +// of the DOM URL API, and accordingly all Adapter methods now +// receive URL objects. +// +// We alias #absoluteURL to #toString() here to avoid crashing +// older adapters which do not expect URL objects. We should +// consider removing this support at some point in the future. + +/** @param {URL} url + * @returns {void} + */ +function extendURLWithDeprecatedProperties(url) { + Object.defineProperties(url, deprecatedLocationPropertyDescriptors) +} + +const deprecatedLocationPropertyDescriptors = { + absoluteURL: { + get() { + return this.toString() + }, + }, +} + +/** @typedef {"on" | "off" | "optin"} FormMode */ +/** @typedef {unknown} TimingData */ +/** @typedef {CustomEvent} TurboBeforeCacheEvent */ +/** + * @typedef {CustomEvent< + * { newBody: HTMLBodyElement; isPreview: boolean } & PageViewRenderOptions + * >} TurboBeforeRenderEvent + */ +/** @typedef {CustomEvent<{ url: string }>} TurboBeforeVisitEvent */ +/** @typedef {CustomEvent<{ url: string; originalEvent: MouseEvent }>} TurboClickEvent */ +/** @typedef {CustomEvent} TurboFrameLoadEvent */ +/** @typedef {CustomEvent<{ newFrame: FrameElement } & FrameViewRenderOptions>} TurboBeforeFrameRenderEvent */ +/** @typedef {CustomEvent<{ fetchResponse: FetchResponse }>} TurboFrameRenderEvent */ +/** @typedef {CustomEvent<{ url: string; timing: TimingData }>} TurboLoadEvent */ +/** @typedef {CustomEvent<{ isPreview: boolean }>} TurboRenderEvent */ +/** @typedef {CustomEvent<{ url: string; action: Action }>} TurboVisitEvent */ diff --git a/src/core/session.ts b/src/core/session.ts deleted file mode 100644 index 31eab98f4..000000000 --- a/src/core/session.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { Adapter } from "./native/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 { 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 } 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" -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; isPreview: boolean } & 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<{ isPreview: boolean }> -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 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, window) - readonly formSubmitObserver = new FormSubmitObserver(this, document) - readonly scrollObserver = new ScrollObserver(this) - readonly streamObserver = new StreamObserver(this) - 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 - } - } - - disable() { - this.enabled = false - } - - stop() { - if (this.started) { - this.pageObserver.stop() - this.cacheObserver.stop() - this.formLinkClickObserver.stop() - this.linkClickObserver.stop() - this.formSubmitObserver.stop() - this.scrollObserver.stop() - this.streamObserver.stop() - this.frameRedirector.stop() - this.history.stop() - this.started = false - } - } - - registerAdapter(adapter: Adapter) { - this.adapter = adapter - } - - visit(location: Locatable, options: Partial = {}) { - 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) { - this.streamObserver.connectStreamSource(source) - } - - disconnectStreamSource(source: StreamSource) { - this.streamObserver.disconnectStreamSource(source) - } - - renderStreamMessage(message: StreamMessage | string) { - this.streamMessageRenderer.render(StreamMessage.wrap(message)) - } - - clearCache() { - this.view.clearSnapshotCache() - } - - setProgressBarDelay(delay: number) { - this.progressBarDelay = delay - } - - setFormMode(mode: FormMode) { - this.formMode = mode - } - - get location() { - return this.history.location - } - - get restorationIdentifier() { - return this.history.restorationIdentifier - } - - // History delegate - - historyPoppedToLocationWithRestorationIdentifier(location: URL, restorationIdentifier: string) { - if (this.enabled) { - this.navigator.startVisit(location, restorationIdentifier, { - action: "restore", - historyChanged: true, - }) - } else { - this.adapter.pageInvalidated({ - reason: "turbo_disabled", - }) - } - } - - // Scroll observer delegate - - scrollPositionChanged(position: Position) { - 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, 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) - const acceptsStreamResponse = link.hasAttribute("data-turbo-stream") - - this.visit(location.href, { action, acceptsStreamResponse }) - } - - // Navigator delegate - - allowsVisitingLocationWithAction(location: URL, action?: Action) { - return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) - } - - visitProposedToLocation(location: URL, options: Partial) { - extendURLWithDeprecatedProperties(location) - this.adapter.visitProposedToLocation(location, options) - } - - visitStarted(visit: Visit) { - if (!visit.acceptsStreamResponse) { - markAsBusy(document.documentElement) - } - extendURLWithDeprecatedProperties(visit.location) - if (!visit.silent) { - this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) - } - } - - visitCompleted(visit: Visit) { - clearBusyState(document.documentElement) - this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) - } - - locationWithActionIsSamePage(location: URL, action?: Action): boolean { - return this.navigator.locationWithActionIsSamePage(location, action) - } - - visitScrolledToSamePageLocation(oldURL: URL, newURL: URL) { - this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) - } - - // Form submit observer delegate - - willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean { - const action = getAction(form, submitter) - - return ( - this.submissionIsNavigatable(form, submitter) && - locationIsVisitable(expandURL(action), this.snapshot.rootLocation) - ) - } - - formSubmitted(form: HTMLFormElement, submitter?: HTMLElement) { - this.navigator.submitForm(form, submitter) - } - - // Page observer delegate - - pageBecameInteractive() { - this.view.lastRenderedLocation = this.location - this.notifyApplicationAfterPageLoad() - } - - pageLoaded() { - this.history.assumeControlOfScrollRestoration() - } - - pageWillUnload() { - this.history.relinquishControlOfScrollRestoration() - } - - // Stream observer delegate - - receivedMessageFromStream(message: StreamMessage) { - this.renderStreamMessage(message) - } - - // Page view delegate - - viewWillCacheSnapshot() { - if (!this.navigator.currentVisit?.silent) { - this.notifyApplicationBeforeCachingSnapshot() - } - } - - allowsImmediateRender({ element }: PageSnapshot, isPreview: boolean, options: PageViewRenderOptions) { - const event = this.notifyApplicationBeforeRender(element, isPreview, options) - const { - defaultPrevented, - detail: { render }, - } = event - - if (this.view.renderer && render) { - this.view.renderer.renderElement = render - } - - return !defaultPrevented - } - - viewRenderedSnapshot(_snapshot: PageSnapshot, isPreview: boolean) { - this.view.lastRenderedLocation = this.history.location - this.notifyApplicationAfterRender(isPreview) - } - - preloadOnLoadLinksForView(element: Element) { - this.preloader.preloadOnLoadLinksForView(element) - } - - viewInvalidated(reason: ReloadReason) { - this.adapter.pageInvalidated(reason) - } - - // Frame element - - frameLoaded(frame: FrameElement) { - this.notifyApplicationAfterFrameLoad(frame) - } - - frameRendered(fetchResponse: FetchResponse, frame: FrameElement) { - this.notifyApplicationAfterFrameRender(fetchResponse, frame) - } - - // Application events - - applicationAllowsFollowingLinkToLocation(link: Element, location: URL, ev: MouseEvent) { - const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) - return !event.defaultPrevented - } - - applicationAllowsVisitingLocation(location: URL) { - const event = this.notifyApplicationBeforeVisitingLocation(location) - return !event.defaultPrevented - } - - 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, - }) - } - - notifyApplicationAfterVisitingLocation(location: URL, action: Action) { - return dispatch("turbo:visit", { detail: { url: location.href, action } }) - } - - notifyApplicationBeforeCachingSnapshot() { - return dispatch("turbo:before-cache") - } - - notifyApplicationBeforeRender(newBody: HTMLBodyElement, isPreview: boolean, options: PageViewRenderOptions) { - return dispatch("turbo:before-render", { - detail: { newBody, isPreview, ...options }, - cancelable: true, - }) - } - - notifyApplicationAfterRender(isPreview: boolean) { - return dispatch("turbo:render", { detail: { isPreview } }) - } - - notifyApplicationAfterPageLoad(timing: TimingData = {}) { - 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(), - }) - ) - } - - notifyApplicationAfterFrameLoad(frame: FrameElement) { - return dispatch("turbo:frame-load", { target: frame }) - } - - notifyApplicationAfterFrameRender(fetchResponse: FetchResponse, frame: FrameElement) { - return dispatch("turbo:frame-render", { - detail: { fetchResponse }, - target: frame, - cancelable: true, - }) - } - - // Helpers - - submissionIsNavigatable(form: HTMLFormElement, submitter?: HTMLElement): boolean { - if (this.formMode == "off") { - return false - } else { - const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true - - 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 { - // Element isn't navigatable by default, unless `data-turbo="true"`. - if (container) { - return container.getAttribute("data-turbo") == "true" - } else { - return false - } - } - } - - // Private - - getActionForLink(link: Element): Action { - return getVisitAction(link) || "advance" - } - - get snapshot() { - return this.view.snapshot - } -} - -// Older versions of the Turbo Native adapters referenced the -// `Location#absoluteURL` property in their implementations of -// the `Adapter#visitProposedToLocation()` and `#visitStarted()` -// methods. The Location class has since been removed in favor -// of the DOM URL API, and accordingly all Adapter methods now -// receive URL objects. -// -// We alias #absoluteURL to #toString() here to avoid crashing -// older adapters which do not expect URL objects. We should -// consider removing this support at some point in the future. - -function extendURLWithDeprecatedProperties(url: URL) { - Object.defineProperties(url, deprecatedLocationPropertyDescriptors) -} - -const deprecatedLocationPropertyDescriptors = { - absoluteURL: { - get() { - return this.toString() - }, - }, -} diff --git a/src/core/snapshot.ts b/src/core/snapshot.js similarity index 61% rename from src/core/snapshot.ts rename to src/core/snapshot.js index 7d5431117..328dfa60f 100644 --- a/src/core/snapshot.ts +++ b/src/core/snapshot.js @@ -1,7 +1,8 @@ -export class Snapshot { - readonly element: E +export class Snapshot { + /** @readonly */ + element = undefined - constructor(element: E) { + constructor(element) { this.element = element } @@ -13,11 +14,17 @@ export class Snapshot { return [...this.element.children] } - hasAnchor(anchor: string | undefined) { + /** @param {string | undefined} anchor + * @returns {boolean} + */ + hasAnchor(anchor) { return this.getElementForAnchor(anchor) != null } - getElementForAnchor(anchor: string | undefined) { + /** @param {string | undefined} anchor + * @returns {Element} + */ + getElementForAnchor(anchor) { return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null } @@ -40,12 +47,18 @@ export class Snapshot { return queryPermanentElementsAll(this.element) } - getPermanentElementById(id: string) { + /** @param {string} id + * @returns {Element} + */ + getPermanentElementById(id) { return getPermanentElementById(this.element, id) } - getPermanentElementMapForSnapshot(snapshot: Snapshot) { - const permanentElementMap: PermanentElementMap = {} + /** @param {Snapshot} snapshot + * @returns {PermanentElementMap} + */ + getPermanentElementMapForSnapshot(snapshot) { + const permanentElementMap = {} for (const currentPermanentElement of this.permanentElements) { const { id } = currentPermanentElement @@ -59,12 +72,19 @@ export class Snapshot { } } -export function getPermanentElementById(node: ParentNode, id: string) { +/** @param {ParentNode} node + * @param {string} id + * @returns {Element} + */ +export function getPermanentElementById(node, id) { return node.querySelector(`#${id}[data-turbo-permanent]`) } -export function queryPermanentElementsAll(node: ParentNode) { +/** @param {ParentNode} node + * @returns {NodeListOf} + */ +export function queryPermanentElementsAll(node) { return node.querySelectorAll("[id][data-turbo-permanent]") } -export type PermanentElementMap = Record +/** @typedef {Record} PermanentElementMap */ diff --git a/src/core/streams/stream_actions.ts b/src/core/streams/stream_actions.js similarity index 77% rename from src/core/streams/stream_actions.ts rename to src/core/streams/stream_actions.js index e4a619f7c..ac43ac9c0 100644 --- a/src/core/streams/stream_actions.ts +++ b/src/core/streams/stream_actions.js @@ -1,9 +1,4 @@ -import { StreamElement } from "../../elements/stream_element" - -export type TurboStreamAction = (this: StreamElement) => void -export type TurboStreamActions = { [action: string]: TurboStreamAction } - -export const StreamActions: TurboStreamActions = { +export const StreamActions = { after() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) }, @@ -37,3 +32,6 @@ export const StreamActions: TurboStreamActions = { }) }, } + +/** @typedef {(this: StreamElement) => void} TurboStreamAction */ +/** @typedef {{ [action: string]: TurboStreamAction }} TurboStreamActions */ diff --git a/src/core/streams/stream_message.ts b/src/core/streams/stream_message.js similarity index 55% rename from src/core/streams/stream_message.ts rename to src/core/streams/stream_message.js index e6ca389a4..a45ee4d87 100644 --- a/src/core/streams/stream_message.ts +++ b/src/core/streams/stream_message.js @@ -1,11 +1,19 @@ -import { StreamElement } from "../../elements/stream_element" import { activateScriptElement, createDocumentFragment } from "../../util" export class StreamMessage { - static readonly contentType = "text/vnd.turbo-stream.html" - readonly fragment: DocumentFragment + /** @static + * @readonly + * @default "text/vnd.turbo-stream.html" + */ + static contentType = "text/vnd.turbo-stream.html" + /** @readonly */ + fragment = undefined - static wrap(message: StreamMessage | string) { + /** @static + * @param {StreamMessage | string} message + * @returns {StreamMessage} + */ + static wrap(message) { if (typeof message == "string") { return new this(createDocumentFragment(message)) } else { @@ -13,13 +21,16 @@ export class StreamMessage { } } - constructor(fragment: DocumentFragment) { + constructor(fragment) { this.fragment = importStreamElements(fragment) } } -function importStreamElements(fragment: DocumentFragment): DocumentFragment { - for (const element of fragment.querySelectorAll("turbo-stream")) { +/** @param {DocumentFragment} fragment + * @returns {DocumentFragment} + */ +function importStreamElements(fragment) { + for (const element of fragment.querySelectorAll("turbo-stream")) { const streamElement = document.importNode(element, true) for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { diff --git a/src/core/streams/stream_message_renderer.ts b/src/core/streams/stream_message_renderer.js similarity index 51% rename from src/core/streams/stream_message_renderer.ts rename to src/core/streams/stream_message_renderer.js index 549e4de8d..f9a327835 100644 --- a/src/core/streams/stream_message_renderer.ts +++ b/src/core/streams/stream_message_renderer.js @@ -1,29 +1,38 @@ -import { StreamMessage } from "./stream_message" -import { StreamElement } from "../../elements/stream_element" -import { Bardo, BardoDelegate } from "../bardo" -import { PermanentElementMap, getPermanentElementById, queryPermanentElementsAll } from "../snapshot" +import { Bardo } from "../bardo" +import { getPermanentElementById, queryPermanentElementsAll } from "../snapshot" -export class StreamMessageRenderer implements BardoDelegate { - render({ fragment }: StreamMessage) { +export class StreamMessageRenderer { + /** @param {StreamMessage} + * @returns {void} + */ + render({ fragment }) { Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => document.documentElement.appendChild(fragment) ) } - enteringBardo(currentPermanentElement: Element, newPermanentElement: Element) { + /** @param {Element} currentPermanentElement + * @param {Element} newPermanentElement + * @returns {void} + */ + enteringBardo(currentPermanentElement, newPermanentElement) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)) } + /** @returns {void} */ leavingBardo() {} } -function getPermanentElementMapForFragment(fragment: DocumentFragment): PermanentElementMap { +/** @param {DocumentFragment} fragment + * @returns {PermanentElementMap} + */ +function getPermanentElementMapForFragment(fragment) { const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement) - const permanentElementMap: PermanentElementMap = {} + const permanentElementMap = {} for (const permanentElementInDocument of permanentElementsInDocument) { const { id } = permanentElementInDocument - for (const streamElement of fragment.querySelectorAll("turbo-stream")) { + for (const streamElement of fragment.querySelectorAll("turbo-stream")) { const elementInStream = getPermanentElementById(streamElement.templateElement.content, id) if (elementInStream) { diff --git a/src/core/types.js b/src/core/types.js new file mode 100644 index 000000000..64eab585a --- /dev/null +++ b/src/core/types.js @@ -0,0 +1,18 @@ +export {} + +/** @typedef {"advance" | "replace" | "restore"} Action */ +/** @typedef {{ x: number; y: number }} Position */ +/** + * @typedef {{ + * addEventListener( + * type: "message", + * listener: (event: MessageEvent) => void, + * options?: boolean | AddEventListenerOptions + * ): void + * removeEventListener( + * type: "message", + * listener: (event: MessageEvent) => void, + * options?: boolean | EventListenerOptions + * ): void + * }} StreamSource + */ diff --git a/src/core/types.ts b/src/core/types.ts deleted file mode 100644 index 90d1817ea..000000000 --- a/src/core/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type Action = "advance" | "replace" | "restore" - -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 -} diff --git a/src/core/url.js b/src/core/url.js new file mode 100644 index 000000000..4ac6eaf7c --- /dev/null +++ b/src/core/url.js @@ -0,0 +1,113 @@ +/** @param {Locatable} locatable + * @returns {URL} + */ +export function expandURL(locatable) { + return new URL(locatable.toString(), document.baseURI) +} + +/** @param {URL} url + * @returns {any} + */ +export function getAnchor(url) { + let anchorMatch + if (url.hash) { + return url.hash.slice(1) + // eslint-disable-next-line no-cond-assign + } else if ((anchorMatch = url.href.match(/#(.*)$/))) { + return anchorMatch[1] + } +} + +/** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {URL} + */ +export function getAction(form, submitter) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action + + return expandURL(action) +} + +/** @param {URL} url + * @returns {string} + */ +export function getExtension(url) { + return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" +} + +/** @param {URL} url + * @returns {boolean} + */ +export function isHTML(url) { + return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) +} + +/** @param {URL} baseURL + * @param {URL} url + * @returns {boolean} + */ +export function isPrefixedBy(baseURL, url) { + const prefix = getPrefix(url) + return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) +} + +/** @param {URL} location + * @param {URL} rootLocation + * @returns {boolean} + */ +export function locationIsVisitable(location, rootLocation) { + return isPrefixedBy(location, rootLocation) && isHTML(location) +} + +/** @param {URL} url + * @returns {string} + */ +export function getRequestURL(url) { + const anchor = getAnchor(url) + return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href +} + +/** @param {URL} url + * @returns {string} + */ +export function toCacheKey(url) { + return getRequestURL(url) +} + +/** @param {string} left + * @param {string} right + * @returns {boolean} + */ +export function urlsAreEqual(left, right) { + return expandURL(left).href == expandURL(right).href +} + +/** @param {URL} url + * @returns {string[]} + */ +function getPathComponents(url) { + return url.pathname.split("/").slice(1) +} + +/** @param {URL} url + * @returns {string} + */ +function getLastPathComponent(url) { + return getPathComponents(url).slice(-1)[0] +} + +/** @param {URL} url + * @returns {string} + */ +function getPrefix(url) { + return addTrailingSlash(url.origin + url.pathname) +} + +/** @param {string} value + * @returns {string} + */ +function addTrailingSlash(value) { + return value.endsWith("/") ? value : value + "/" +} + +/** @typedef {URL | string} Locatable */ diff --git a/src/core/url.ts b/src/core/url.ts deleted file mode 100644 index 0e45d8f2b..000000000 --- a/src/core/url.ts +++ /dev/null @@ -1,67 +0,0 @@ -export type Locatable = URL | string - -export function expandURL(locatable: Locatable) { - return new URL(locatable.toString(), document.baseURI) -} - -export function getAnchor(url: URL) { - let anchorMatch - if (url.hash) { - return url.hash.slice(1) - // 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|php))$/) -} - -export function isPrefixedBy(baseURL: URL, url: URL) { - const prefix = getPrefix(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 -} - -export function toCacheKey(url: URL) { - return getRequestURL(url) -} - -export function urlsAreEqual(left: string, right: string) { - return expandURL(left).href == expandURL(right).href -} - -function getPathComponents(url: URL) { - return url.pathname.split("/").slice(1) -} - -function getLastPathComponent(url: URL) { - return getPathComponents(url).slice(-1)[0] -} - -function getPrefix(url: URL) { - return addTrailingSlash(url.origin + url.pathname) -} - -function addTrailingSlash(value: string) { - return value.endsWith("/") ? value : value + "/" -} diff --git a/src/core/view.ts b/src/core/view.js similarity index 58% rename from src/core/view.ts rename to src/core/view.js index 20109a35d..e716e87d2 100644 --- a/src/core/view.ts +++ b/src/core/view.js @@ -1,43 +1,34 @@ -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 ViewRenderOptions { - resume: (value?: any) => void - render: Render -} - -export interface ViewDelegate> { - allowsImmediateRender(snapshot: S, isPreview: boolean, options: ViewRenderOptions): boolean - preloadOnLoadLinksForView(element: Element): void - viewRenderedSnapshot(snapshot: S, isPreview: boolean): void - viewInvalidated(reason: ReloadReason): void -} - -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) => {} - - constructor(delegate: D, element: E) { +export class View { + /** @readonly */ + delegate = undefined + /** @readonly */ + element = undefined + /** */ + renderer = undefined + /** */ + renderPromise = undefined + /** @private + * @default (_value: any) => {} + */ + resolveRenderPromise = (_value) => {} + /** @private + * @default (_value: any) => {} + */ + resolveInterceptionPromise = (_value) => {} + + constructor(delegate, element) { this.delegate = delegate this.element = element } // Scrolling - scrollToAnchor(anchor: string | undefined) { + /** @param {string | undefined} anchor + * @returns {void} + */ + scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor) if (element) { this.scrollToElement(element) @@ -47,15 +38,24 @@ export abstract class View< } } - scrollToAnchorFromLocation(location: URL) { + /** @param {URL} location + * @returns {void} + */ + scrollToAnchorFromLocation(location) { this.scrollToAnchor(getAnchor(location)) } - scrollToElement(element: Element) { + /** @param {Element} element + * @returns {void} + */ + scrollToElement(element) { element.scrollIntoView() } - focusElement(element: Element) { + /** @param {Element} element + * @returns {void} + */ + focusElement(element) { if (element instanceof HTMLElement) { if (element.hasAttribute("tabindex")) { element.focus() @@ -67,21 +67,28 @@ export abstract class View< } } - scrollToPosition({ x, y }: Position) { + /** @param {Position} + * @returns {void} + */ + scrollToPosition({ x, y }) { this.scrollRoot.scrollTo(x, y) } + /** @returns {void} */ scrollToTop() { this.scrollToPosition({ x: 0, y: 0 }) } - get scrollRoot(): { scrollTo(x: number, y: number): void } { + get scrollRoot() { return window } // Rendering - async render(renderer: R) { + /** @param {R} renderer + * @returns {Promise} + */ + async render(renderer) { const { isPreview, shouldRender, newSnapshot: snapshot } = renderer if (shouldRender) { try { @@ -108,16 +115,25 @@ export abstract class View< } } - invalidate(reason: ReloadReason) { + /** @param {ReloadReason} reason + * @returns {void} + */ + invalidate(reason) { this.delegate.viewInvalidated(reason) } - async prepareToRenderSnapshot(renderer: R) { + /** @param {R} renderer + * @returns {Promise} + */ + async prepareToRenderSnapshot(renderer) { this.markAsPreview(renderer.isPreview) await renderer.prepareToRender() } - markAsPreview(isPreview: boolean) { + /** @param {boolean} isPreview + * @returns {void} + */ + markAsPreview(isPreview) { if (isPreview) { this.element.setAttribute("data-turbo-preview", "") } else { @@ -125,11 +141,23 @@ export abstract class View< } } - async renderSnapshot(renderer: R) { + /** @param {R} renderer + * @returns {Promise} + */ + async renderSnapshot(renderer) { await renderer.render() } - finishRenderingSnapshot(renderer: R) { + /** @param {R} renderer + * @returns {void} + */ + finishRenderingSnapshot(renderer) { renderer.finishRendering() } } + +/** @typedef {Object} ViewRenderOptions + * @property {(value?:any)=>void} resume + * @property {Render} render + */ +/** @typedef {Object} ViewDelegate */ diff --git a/src/elements/frame_element.ts b/src/elements/frame_element.js similarity index 67% rename from src/elements/frame_element.ts rename to src/elements/frame_element.js index 8a177a6d7..aa2c4124f 100644 --- a/src/elements/frame_element.ts +++ b/src/elements/frame_element.js @@ -1,29 +1,8 @@ -import { FetchResponse } from "../http/fetch_response" -import { Snapshot } from "../core/snapshot" -import { LinkInterceptorDelegate } from "../core/frames/link_interceptor" -import { FormSubmitObserverDelegate } from "../observers/form_submit_observer" - -export enum FrameLoadingStyle { - eager = "eager", - lazy = "lazy", -} - -export type FrameElementObservedAttribute = keyof FrameElement & ("disabled" | "complete" | "loading" | "src") - -export interface FrameElementDelegate extends LinkInterceptorDelegate, FormSubmitObserverDelegate { - connect(): void - disconnect(): void - completeChanged(): void - loadingStyleChanged(): void - sourceURLChanged(): void - sourceURLReloaded(): Promise - disabledChanged(): void - loadResponse(response: FetchResponse): void - proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement): void - fetchResponseLoaded: (fetchResponse: FetchResponse) => void - visitCachedSnapshot: (snapshot: Snapshot) => void - isLoading: boolean -} +export var FrameLoadingStyle +;(function (FrameLoadingStyle) { + FrameLoadingStyle["eager"] = "eager" + FrameLoadingStyle["lazy"] = "lazy" +})(FrameLoadingStyle || (FrameLoadingStyle = {})) /** * Contains a fragment of HTML which is updated based on navigation within @@ -40,14 +19,19 @@ export interface FrameElementDelegate extends LinkInterceptorDelegate, FormSubmi * Show response from this form within this frame. * * + * @extends HTMLElement */ export class FrameElement extends HTMLElement { - static delegateConstructor: new (element: FrameElement) => FrameElementDelegate + /** @static */ + static delegateConstructor = undefined - loaded: Promise = Promise.resolve() - readonly delegate: FrameElementDelegate + /** @default Promise.resolve() */ + loaded = Promise.resolve() + /** @readonly */ + delegate = undefined - static get observedAttributes(): FrameElementObservedAttribute[] { + /** @static */ + static get observedAttributes() { return ["disabled", "complete", "loading", "src"] } @@ -56,19 +40,25 @@ export class FrameElement extends HTMLElement { this.delegate = new FrameElement.delegateConstructor(this) } + /** @returns {void} */ connectedCallback() { this.delegate.connect() } + /** @returns {void} */ disconnectedCallback() { this.delegate.disconnect() } - reload(): Promise { + /** @returns {Promise} */ + reload() { return this.delegate.sourceURLReloaded() } - attributeChangedCallback(name: string) { + /** @param {string} name + * @returns {void} + */ + attributeChangedCallback(name) { if (name == "loading") { this.delegate.loadingStyleChanged() } else if (name == "complete") { @@ -90,7 +80,7 @@ export class FrameElement extends HTMLElement { /** * Sets the URL to lazily load source HTML from */ - set src(value: string | null) { + set src(value) { if (value) { this.setAttribute("src", value) } else { @@ -101,14 +91,14 @@ export class FrameElement extends HTMLElement { /** * Determines if the element is loading */ - get loading(): FrameLoadingStyle { + get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || "") } /** * Sets the value of if the element is loading */ - set loading(value: FrameLoadingStyle) { + set loading(value) { if (value) { this.setAttribute("loading", value) } else { @@ -130,7 +120,7 @@ export class FrameElement extends HTMLElement { * * If disabled, no requests will be intercepted by the frame. */ - set disabled(value: boolean) { + set disabled(value) { if (value) { this.setAttribute("disabled", "") } else { @@ -152,7 +142,7 @@ export class FrameElement extends HTMLElement { * * If true, the frame will be scrolled into view automatically on update. */ - set autoscroll(value: boolean) { + set autoscroll(value) { if (value) { this.setAttribute("autoscroll", "") } else { @@ -186,7 +176,10 @@ export class FrameElement extends HTMLElement { } } -function frameLoadingStyleFromString(style: string) { +/** @param {string} style + * @returns {FrameLoadingStyle} + */ +function frameLoadingStyleFromString(style) { switch (style.toLowerCase()) { case "lazy": return FrameLoadingStyle.lazy @@ -194,3 +187,11 @@ function frameLoadingStyleFromString(style: string) { return FrameLoadingStyle.eager } } + +/** @typedef {keyof FrameElement & ("disabled" | "complete" | "loading" | "src")} FrameElementObservedAttribute */ + +/** @typedef {Object} FrameElementDelegate + * @property {(fetchResponse:FetchResponse)=>void} fetchResponseLoaded + * @property {(snapshot:Snapshot)=>void} visitCachedSnapshot + * @property {boolean} isLoading + */ diff --git a/src/elements/index.ts b/src/elements/index.js similarity index 100% rename from src/elements/index.ts rename to src/elements/index.js diff --git a/src/elements/stream_element.ts b/src/elements/stream_element.js similarity index 84% rename from src/elements/stream_element.ts rename to src/elements/stream_element.js index e0213f97d..29329a9de 100644 --- a/src/elements/stream_element.ts +++ b/src/elements/stream_element.js @@ -1,10 +1,6 @@ import { StreamActions } from "../core/streams/stream_actions" import { nextAnimationFrame } from "../util" -type Render = (currentElement: StreamElement) => Promise - -export type TurboBeforeStreamRenderEvent = CustomEvent<{ newStream: StreamElement; render: Render }> - // * + * @extends HTMLElement */ export class StreamElement extends HTMLElement { - static async renderElement(newElement: StreamElement): Promise { + /** @static + * @param {StreamElement} newElement + * @returns {Promise} + */ + static async renderElement(newElement) { await newElement.performAction() } + /** @returns {Promise} */ async connectedCallback() { try { await this.render() @@ -42,8 +44,10 @@ export class StreamElement extends HTMLElement { } } - private renderPromise?: Promise + /** @private */ + renderPromise = undefined + /** @returns {Promise} */ async render() { return (this.renderPromise ??= (async () => { const event = this.beforeRenderEvent @@ -55,6 +59,7 @@ export class StreamElement extends HTMLElement { })()) } + /** @returns {void} */ disconnect() { try { this.remove() @@ -64,6 +69,7 @@ export class StreamElement extends HTMLElement { /** * Removes duplicate children (by ID) + * @returns {void} */ removeDuplicateTargetChildren() { this.duplicateChildren.forEach((c) => c.remove()) @@ -149,15 +155,21 @@ export class StreamElement extends HTMLElement { return this.getAttribute("targets") } - private raise(message: string): never { + /** @private + * @param {string} message + * @returns {never} + */ + raise(message) { throw new Error(`${this.description}: ${message}`) } - private get description() { + /** @private */ + get description() { return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "" } - private get beforeRenderEvent(): TurboBeforeStreamRenderEvent { + /** @private */ + get beforeRenderEvent() { return new CustomEvent("turbo:before-stream-render", { bubbles: true, cancelable: true, @@ -165,8 +177,9 @@ export class StreamElement extends HTMLElement { }) } - private get targetElementsById() { - const element = this.ownerDocument?.getElementById(this.target!) + /** @private */ + get targetElementsById() { + const element = this.ownerDocument?.getElementById(this.target) if (element !== null) { return [element] @@ -175,8 +188,9 @@ export class StreamElement extends HTMLElement { } } - private get targetElementsByQuery() { - const elements = this.ownerDocument?.querySelectorAll(this.targets!) + /** @private */ + get targetElementsByQuery() { + const elements = this.ownerDocument?.querySelectorAll(this.targets) if (elements.length !== 0) { return Array.prototype.slice.call(elements) @@ -185,3 +199,6 @@ export class StreamElement extends HTMLElement { } } } + +/** @typedef {(currentElement: StreamElement) => Promise} Render */ +/** @typedef {CustomEvent<{ newStream: StreamElement; render: Render }>} TurboBeforeStreamRenderEvent */ diff --git a/src/elements/stream_source_element.ts b/src/elements/stream_source_element.js similarity index 77% rename from src/elements/stream_source_element.ts rename to src/elements/stream_source_element.js index 48f97e5fe..b90ce9e82 100644 --- a/src/elements/stream_source_element.ts +++ b/src/elements/stream_source_element.js @@ -1,22 +1,25 @@ -import { StreamSource } from "../core/types" import { connectStreamSource, disconnectStreamSource } from "../core/index" +/** @extends HTMLElement */ export class StreamSourceElement extends HTMLElement { - streamSource: StreamSource | null = null + /** @default null */ + streamSource = null + /** @returns {void} */ connectedCallback() { this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src) connectStreamSource(this.streamSource) } + /** @returns {void} */ disconnectedCallback() { if (this.streamSource) { disconnectStreamSource(this.streamSource) } } - get src(): string { + get src() { return this.getAttribute("src") || "" } } diff --git a/src/globals.d.ts b/src/globals.d.ts deleted file mode 100644 index 25f5dec19..000000000 --- a/src/globals.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -interface SubmitEvent extends Event { - submitter: HTMLElement | null -} - -interface Node { - // https://github.com/Microsoft/TypeScript/issues/283 - cloneNode(deep?: boolean): this -} - -interface Window { - Turbo: typeof import("./core/index") - SubmitEvent: typeof Event -} diff --git a/src/http/fetch_request.js b/src/http/fetch_request.js new file mode 100644 index 000000000..513b781fd --- /dev/null +++ b/src/http/fetch_request.js @@ -0,0 +1,214 @@ +import { FetchResponse } from "./fetch_response" +import { dispatch } from "../util" + +export var FetchMethod +;(function (FetchMethod) { + FetchMethod[(FetchMethod["get"] = 0)] = "get" + FetchMethod[(FetchMethod["post"] = 1)] = "post" + FetchMethod[(FetchMethod["put"] = 2)] = "put" + FetchMethod[(FetchMethod["patch"] = 3)] = "patch" + FetchMethod[(FetchMethod["delete"] = 4)] = "delete" +})(FetchMethod || (FetchMethod = {})) + +/** @param {string} method + * @returns {FetchMethod} + */ +export function fetchMethodFromString(method) { + switch (method.toLowerCase()) { + case "get": + return FetchMethod.get + case "post": + return FetchMethod.post + case "put": + return FetchMethod.put + case "patch": + return FetchMethod.patch + case "delete": + return FetchMethod.delete + } +} + +export class FetchRequest { + /** @readonly */ + delegate = undefined + /** @readonly */ + method = undefined + /** @readonly */ + headers = undefined + /** @readonly */ + url = undefined + /** @readonly */ + body = undefined + /** @readonly */ + target = undefined + /** @readonly + * @default new AbortController() + */ + abortController = new AbortController() + /** @private + * @default (_value: any) => {} + */ + resolveRequestPromise = (_value) => {} + + constructor(delegate, method, location, body = new URLSearchParams(), target = null) { + this.delegate = delegate + this.method = method + this.headers = this.defaultHeaders + this.body = body + this.url = location + this.target = target + } + + get location() { + return this.url + } + + get params() { + return this.url.searchParams + } + + get entries() { + return this.body ? Array.from(this.body.entries()) : [] + } + + /** @returns {void} */ + cancel() { + this.abortController.abort() + } + + /** @returns {Promise} */ + async perform() { + const { fetchOptions } = this + this.delegate.prepareRequest(this) + await this.allowRequestToBeIntercepted(fetchOptions) + try { + this.delegate.requestStarted(this) + const response = await fetch(this.url.href, fetchOptions) + return await this.receive(response) + } catch (error) { + if (error.name !== "AbortError") { + if (this.willDelegateErrorHandling(error)) { + this.delegate.requestErrored(this, error) + } + throw error + } + } finally { + this.delegate.requestFinished(this) + } + } + + /** @param {Response} response + * @returns {Promise} + */ + async receive(response) { + const fetchResponse = new FetchResponse(response) + const event = dispatch("turbo:before-fetch-response", { + cancelable: true, + detail: { fetchResponse }, + target: this.target, + }) + if (event.defaultPrevented) { + this.delegate.requestPreventedHandlingResponse(this, fetchResponse) + } else if (fetchResponse.succeeded) { + this.delegate.requestSucceededWithResponse(this, fetchResponse) + } else { + this.delegate.requestFailedWithResponse(this, fetchResponse) + } + return fetchResponse + } + + get fetchOptions() { + return { + method: FetchMethod[this.method].toUpperCase(), + credentials: "same-origin", + headers: this.headers, + redirect: "follow", + body: this.isSafe ? null : this.body, + signal: this.abortSignal, + referrer: this.delegate.referrer?.href, + } + } + + get defaultHeaders() { + return { + Accept: "text/html, application/xhtml+xml", + } + } + + get isSafe() { + return this.method === FetchMethod.get + } + + get abortSignal() { + return this.abortController.signal + } + + /** @param {string} mimeType + * @returns {void} + */ + acceptResponseType(mimeType) { + this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ") + } + + /** @private + * @param {RequestInit} fetchOptions + * @returns {Promise} + */ + async allowRequestToBeIntercepted(fetchOptions) { + const requestInterception = new Promise((resolve) => (this.resolveRequestPromise = resolve)) + const event = dispatch("turbo:before-fetch-request", { + cancelable: true, + detail: { + fetchOptions, + url: this.url, + resume: this.resolveRequestPromise, + }, + target: this.target, + }) + if (event.defaultPrevented) await requestInterception + } + + /** @private + * @param {Error} error + * @returns {boolean} + */ + willDelegateErrorHandling(error) { + const event = dispatch("turbo:fetch-request-error", { + target: this.target, + cancelable: true, + detail: { request: this, error: error }, + }) + + return !event.defaultPrevented + } +} + +/** + * @typedef {CustomEvent<{ + * fetchOptions: RequestInit + * url: URL + * resume: (value?: any) => void + * }>} TurboBeforeFetchRequestEvent + */ +/** + * @typedef {CustomEvent<{ + * fetchResponse: FetchResponse + * }>} TurboBeforeFetchResponseEvent + */ +/** + * @typedef {CustomEvent<{ + * request: FetchRequest + * error: Error + * }>} TurboFetchRequestErrorEvent + */ +/** @typedef {FormData | URLSearchParams} FetchRequestBody */ +/** @typedef {{ [header: string]: string }} FetchRequestHeaders */ + +/** @typedef {Object} FetchRequestDelegate + * @property {URL} [referrer] + */ +/** @typedef {Object} FetchRequestOptions + * @property {FetchRequestHeaders} headers + * @property {FetchRequestBody} body + * @property {boolean} followRedirects + */ diff --git a/src/http/fetch_request.ts b/src/http/fetch_request.ts deleted file mode 100644 index 5086392b2..000000000 --- a/src/http/fetch_request.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { FetchResponse } from "./fetch_response" -import { FrameElement } from "../elements/frame_element" -import { dispatch } from "../util" - -export type TurboBeforeFetchRequestEvent = CustomEvent<{ - fetchOptions: RequestInit - url: URL - resume: (value?: any) => void -}> -export type TurboBeforeFetchResponseEvent = CustomEvent<{ - fetchResponse: FetchResponse -}> -export type TurboFetchRequestErrorEvent = CustomEvent<{ - request: FetchRequest - error: Error -}> - -export interface FetchRequestDelegate { - referrer?: URL - - prepareRequest(request: FetchRequest): void - requestStarted(request: FetchRequest): void - requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse): void - requestSucceededWithResponse(request: FetchRequest, response: FetchResponse): void - requestFailedWithResponse(request: FetchRequest, response: FetchResponse): void - requestErrored(request: FetchRequest, error: Error): void - requestFinished(request: FetchRequest): void -} - -export enum FetchMethod { - get, - post, - put, - patch, - delete, -} - -export function fetchMethodFromString(method: string) { - switch (method.toLowerCase()) { - case "get": - return FetchMethod.get - case "post": - return FetchMethod.post - case "put": - return FetchMethod.put - case "patch": - return FetchMethod.patch - case "delete": - return FetchMethod.delete - } -} - -export type FetchRequestBody = FormData | URLSearchParams - -export type FetchRequestHeaders = { [header: string]: string } - -export interface FetchRequestOptions { - headers: FetchRequestHeaders - body: FetchRequestBody - followRedirects: boolean -} - -export class FetchRequest { - readonly delegate: FetchRequestDelegate - readonly method: FetchMethod - readonly headers: FetchRequestHeaders - readonly url: URL - readonly body?: FetchRequestBody - readonly target?: FrameElement | HTMLFormElement | null - readonly abortController = new AbortController() - private resolveRequestPromise = (_value: any) => {} - - constructor( - delegate: FetchRequestDelegate, - method: FetchMethod, - location: URL, - body: FetchRequestBody = new URLSearchParams(), - target: FrameElement | HTMLFormElement | null = null - ) { - this.delegate = delegate - this.method = method - this.headers = this.defaultHeaders - this.body = body - this.url = location - this.target = target - } - - get location(): URL { - return this.url - } - - get params(): URLSearchParams { - return this.url.searchParams - } - - get entries() { - return this.body ? Array.from(this.body.entries()) : [] - } - - cancel() { - this.abortController.abort() - } - - async perform(): Promise { - const { fetchOptions } = this - this.delegate.prepareRequest(this) - await this.allowRequestToBeIntercepted(fetchOptions) - try { - this.delegate.requestStarted(this) - const response = await fetch(this.url.href, fetchOptions) - return await this.receive(response) - } catch (error) { - if ((error as Error).name !== "AbortError") { - if (this.willDelegateErrorHandling(error as Error)) { - this.delegate.requestErrored(this, error as Error) - } - throw error - } - } finally { - this.delegate.requestFinished(this) - } - } - - async receive(response: Response): Promise { - const fetchResponse = new FetchResponse(response) - const event = dispatch("turbo:before-fetch-response", { - cancelable: true, - detail: { fetchResponse }, - target: this.target as EventTarget, - }) - if (event.defaultPrevented) { - this.delegate.requestPreventedHandlingResponse(this, fetchResponse) - } else if (fetchResponse.succeeded) { - this.delegate.requestSucceededWithResponse(this, fetchResponse) - } else { - this.delegate.requestFailedWithResponse(this, fetchResponse) - } - return fetchResponse - } - - get fetchOptions(): RequestInit { - return { - method: FetchMethod[this.method].toUpperCase(), - credentials: "same-origin", - headers: this.headers, - redirect: "follow", - body: this.isSafe ? null : this.body, - signal: this.abortSignal, - referrer: this.delegate.referrer?.href, - } - } - - get defaultHeaders() { - return { - Accept: "text/html, application/xhtml+xml", - } - } - - get isSafe() { - return this.method === FetchMethod.get - } - - get abortSignal() { - return this.abortController.signal - } - - acceptResponseType(mimeType: string) { - this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ") - } - - private async allowRequestToBeIntercepted(fetchOptions: RequestInit) { - const requestInterception = new Promise((resolve) => (this.resolveRequestPromise = resolve)) - const event = dispatch("turbo:before-fetch-request", { - cancelable: true, - detail: { - fetchOptions, - url: this.url, - resume: this.resolveRequestPromise, - }, - target: this.target as EventTarget, - }) - if (event.defaultPrevented) await requestInterception - } - - private willDelegateErrorHandling(error: Error) { - const event = dispatch("turbo:fetch-request-error", { - target: this.target as EventTarget, - cancelable: true, - detail: { request: this, error: error }, - }) - - return !event.defaultPrevented - } -} diff --git a/src/http/fetch_response.ts b/src/http/fetch_response.js similarity index 82% rename from src/http/fetch_response.ts rename to src/http/fetch_response.js index 88fbdce14..7aa69abf3 100644 --- a/src/http/fetch_response.ts +++ b/src/http/fetch_response.js @@ -1,9 +1,10 @@ import { expandURL } from "../core/url" export class FetchResponse { - readonly response: Response + /** @readonly */ + response = undefined - constructor(response: Response) { + constructor(response) { this.response = response } @@ -27,7 +28,7 @@ export class FetchResponse { return this.response.redirected } - get location(): URL { + get location() { return expandURL(this.response.url) } @@ -43,11 +44,11 @@ export class FetchResponse { return this.header("Content-Type") } - get responseText(): Promise { + get responseText() { return this.response.clone().text() } - get responseHTML(): Promise { + get responseHTML() { if (this.isHTML) { return this.response.clone().text() } else { @@ -55,7 +56,10 @@ export class FetchResponse { } } - header(name: string) { + /** @param {string} name + * @returns {string} + */ + header(name) { return this.response.headers.get(name) } } diff --git a/src/http/index.js b/src/http/index.js new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/src/http/index.js @@ -0,0 +1 @@ +export {} diff --git a/src/http/index.ts b/src/http/index.ts deleted file mode 100644 index 9c1fa8e4f..000000000 --- a/src/http/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { - TurboBeforeFetchRequestEvent, - TurboBeforeFetchResponseEvent, - TurboFetchRequestErrorEvent, -} from "./fetch_request" diff --git a/src/index.ts b/src/index.js similarity index 100% rename from src/index.ts rename to src/index.js diff --git a/src/observers/appearance_observer.js b/src/observers/appearance_observer.js new file mode 100644 index 000000000..53af263ec --- /dev/null +++ b/src/observers/appearance_observer.js @@ -0,0 +1,49 @@ +export class AppearanceObserver { + /** @readonly */ + delegate = undefined + /** @readonly */ + element = undefined + /** @readonly */ + intersectionObserver = undefined + /** @default false */ + started = false + + constructor(delegate, element) { + this.delegate = delegate + this.element = element + this.intersectionObserver = new IntersectionObserver(this.intersect) + } + + /** @returns {void} */ + start() { + if (!this.started) { + this.started = true + this.intersectionObserver.observe(this.element) + } + } + + /** @returns {void} */ + stop() { + if (this.started) { + this.started = false + this.intersectionObserver.unobserve(this.element) + } + } + + /** + * @default (entries) => { + * const lastEntry = entries.slice(-1)[0] + * if (lastEntry?.isIntersecting) { + * this.delegate.elementAppearedInViewport(this.element) + * } + * } + */ + intersect = (entries) => { + const lastEntry = entries.slice(-1)[0] + if (lastEntry?.isIntersecting) { + this.delegate.elementAppearedInViewport(this.element) + } + } +} + +/** @typedef {Object} AppearanceObserverDelegate */ diff --git a/src/observers/appearance_observer.ts b/src/observers/appearance_observer.ts deleted file mode 100644 index 7a622a481..000000000 --- a/src/observers/appearance_observer.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface AppearanceObserverDelegate { - elementAppearedInViewport(element: T): void -} - -export class AppearanceObserver { - readonly delegate: AppearanceObserverDelegate - readonly element: T - readonly intersectionObserver: IntersectionObserver - started = false - - constructor(delegate: AppearanceObserverDelegate, element: T) { - this.delegate = delegate - this.element = element - this.intersectionObserver = new IntersectionObserver(this.intersect) - } - - start() { - if (!this.started) { - this.started = true - this.intersectionObserver.observe(this.element) - } - } - - stop() { - if (this.started) { - this.started = false - this.intersectionObserver.unobserve(this.element) - } - } - - intersect: IntersectionObserverCallback = (entries) => { - const lastEntry = entries.slice(-1)[0] - if (lastEntry?.isIntersecting) { - this.delegate.elementAppearedInViewport(this.element) - } - } -} diff --git a/src/observers/cache_observer.ts b/src/observers/cache_observer.js similarity index 64% rename from src/observers/cache_observer.ts rename to src/observers/cache_observer.js index 73d7707f7..643044a84 100644 --- a/src/observers/cache_observer.ts +++ b/src/observers/cache_observer.js @@ -1,11 +1,17 @@ -import { TurboBeforeCacheEvent } from "../core/session" - export class CacheObserver { - readonly selector: string = "[data-turbo-temporary]" - readonly deprecatedSelector: string = "[data-turbo-cache=false]" - + /** @readonly + * @default "[data-turbo-temporary]" + */ + selector = "[data-turbo-temporary]" + /** @readonly + * @default "[data-turbo-cache=false]" + */ + deprecatedSelector = "[data-turbo-cache=false]" + + /** @default false */ started = false + /** @returns {void} */ start() { if (!this.started) { this.started = true @@ -13,6 +19,7 @@ export class CacheObserver { } } + /** @returns {void} */ stop() { if (this.started) { this.started = false @@ -20,11 +27,18 @@ export class CacheObserver { } } - removeTemporaryElements = ((_event: TurboBeforeCacheEvent) => { + /** + * @default ((_event: TurboBeforeCacheEvent) => { + * for (const element of this.temporaryElements) { + * element.remove() + * } + * }) + */ + removeTemporaryElements = (_event) => { for (const element of this.temporaryElements) { element.remove() } - }) + } get temporaryElements() { return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation] diff --git a/src/observers/form_link_click_observer.ts b/src/observers/form_link_click_observer.js similarity index 67% rename from src/observers/form_link_click_observer.ts rename to src/observers/form_link_click_observer.js index 8ea08597e..b54753125 100644 --- a/src/observers/form_link_click_observer.ts +++ b/src/observers/form_link_click_observer.js @@ -1,36 +1,44 @@ -import { LinkClickObserver, LinkClickObserverDelegate } from "./link_click_observer" +import { LinkClickObserver } from "./link_click_observer" import { getVisitAction } from "../util" -export type FormLinkClickObserverDelegate = { - willSubmitFormLinkToLocation(link: Element, location: URL, event: MouseEvent): boolean - submittedFormLinkToLocation(link: Element, location: URL, form: HTMLFormElement): void -} - -export class FormLinkClickObserver implements LinkClickObserverDelegate { - readonly linkInterceptor: LinkClickObserver - readonly delegate: FormLinkClickObserverDelegate +export class FormLinkClickObserver { + /** @readonly */ + linkInterceptor = undefined + /** @readonly */ + delegate = undefined - constructor(delegate: FormLinkClickObserverDelegate, element: HTMLElement) { + constructor(delegate, element) { this.delegate = delegate this.linkInterceptor = new LinkClickObserver(this, element) } + /** @returns {void} */ start() { this.linkInterceptor.start() } + /** @returns {void} */ stop() { this.linkInterceptor.stop() } - willFollowLinkToLocation(link: Element, location: URL, originalEvent: MouseEvent): boolean { + /** @param {Element} link + * @param {URL} location + * @param {MouseEvent} originalEvent + * @returns {boolean} + */ + willFollowLinkToLocation(link, location, originalEvent) { return ( this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && link.hasAttribute("data-turbo-method") ) } - followedLinkToLocation(link: Element, location: URL): void { + /** @param {Element} link + * @param {URL} location + * @returns {void} + */ + followedLinkToLocation(link, location) { const form = document.createElement("form") const type = "hidden" @@ -65,3 +73,10 @@ export class FormLinkClickObserver implements LinkClickObserverDelegate { requestAnimationFrame(() => form.requestSubmit()) } } + +/** + * @typedef {{ + * willSubmitFormLinkToLocation(link: Element, location: URL, event: MouseEvent): boolean + * submittedFormLinkToLocation(link: Element, location: URL, form: HTMLFormElement): void + * }} FormLinkClickObserverDelegate + */ diff --git a/src/observers/form_submit_observer.ts b/src/observers/form_submit_observer.js similarity index 51% rename from src/observers/form_submit_observer.ts rename to src/observers/form_submit_observer.js index 3700d0726..ff165b87c 100644 --- a/src/observers/form_submit_observer.ts +++ b/src/observers/form_submit_observer.js @@ -1,18 +1,17 @@ -export interface FormSubmitObserverDelegate { - willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean - formSubmitted(form: HTMLFormElement, submitter?: HTMLElement): void -} - export class FormSubmitObserver { - readonly delegate: FormSubmitObserverDelegate - readonly eventTarget: EventTarget + /** @readonly */ + delegate = undefined + /** @readonly */ + eventTarget = undefined + /** @default false */ started = false - constructor(delegate: FormSubmitObserverDelegate, eventTarget: EventTarget) { + constructor(delegate, eventTarget) { this.delegate = delegate this.eventTarget = eventTarget } + /** @returns {void} */ start() { if (!this.started) { this.eventTarget.addEventListener("submit", this.submitCaptured, true) @@ -20,6 +19,7 @@ export class FormSubmitObserver { } } + /** @returns {void} */ stop() { if (this.started) { this.eventTarget.removeEventListener("submit", this.submitCaptured, true) @@ -27,12 +27,37 @@ export class FormSubmitObserver { } } + /** + * @default () => { + * this.eventTarget.removeEventListener("submit", this.submitBubbled, false) + * this.eventTarget.addEventListener("submit", this.submitBubbled, false) + * } + */ submitCaptured = () => { this.eventTarget.removeEventListener("submit", this.submitBubbled, false) this.eventTarget.addEventListener("submit", this.submitBubbled, false) } - submitBubbled = ((event: SubmitEvent) => { + /** + * @default ((event: SubmitEvent) => { + * if (!event.defaultPrevented) { + * const form = event.target instanceof HTMLFormElement ? event.target : undefined + * const submitter = event.submitter || undefined + * // TS-TO-JSDOC BLANK LINE // + * if ( + * form && + * submissionDoesNotDismissDialog(form, submitter) && + * submissionDoesNotTargetIFrame(form, submitter) && + * this.delegate.willSubmitForm(form, submitter) + * ) { + * event.preventDefault() + * event.stopImmediatePropagation() + * this.delegate.formSubmitted(form, submitter) + * } + * } + * }) + */ + submitBubbled = (event) => { if (!event.defaultPrevented) { const form = event.target instanceof HTMLFormElement ? event.target : undefined const submitter = event.submitter || undefined @@ -48,16 +73,24 @@ export class FormSubmitObserver { this.delegate.formSubmitted(form, submitter) } } - }) + } } -function submissionDoesNotDismissDialog(form: HTMLFormElement, submitter?: HTMLElement): boolean { +/** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ +function submissionDoesNotDismissDialog(form, submitter) { const method = submitter?.getAttribute("formmethod") || form.getAttribute("method") return method != "dialog" } -function submissionDoesNotTargetIFrame(form: HTMLFormElement, submitter?: HTMLElement): boolean { +/** @param {HTMLFormElement} form + * @param {HTMLElement} [submitter] + * @returns {boolean} + */ +function submissionDoesNotTargetIFrame(form, submitter) { if (submitter?.hasAttribute("formtarget") || form.hasAttribute("target")) { const target = submitter?.getAttribute("formtarget") || form.target @@ -70,3 +103,5 @@ function submissionDoesNotTargetIFrame(form: HTMLFormElement, submitter?: HTMLEl return true } } + +/** @typedef {Object} FormSubmitObserverDelegate */ diff --git a/src/observers/link_click_observer.ts b/src/observers/link_click_observer.js similarity index 50% rename from src/observers/link_click_observer.ts rename to src/observers/link_click_observer.js index b5d7c442f..349764fca 100644 --- a/src/observers/link_click_observer.ts +++ b/src/observers/link_click_observer.js @@ -1,21 +1,20 @@ import { expandURL } from "../core/url" import { findClosestRecursively } from "../util" -export interface LinkClickObserverDelegate { - willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent): boolean - followedLinkToLocation(link: Element, location: URL): void -} - export class LinkClickObserver { - readonly delegate: LinkClickObserverDelegate - readonly eventTarget: EventTarget + /** @readonly */ + delegate = undefined + /** @readonly */ + eventTarget = undefined + /** @default false */ started = false - constructor(delegate: LinkClickObserverDelegate, eventTarget: EventTarget) { + constructor(delegate, eventTarget) { this.delegate = delegate this.eventTarget = eventTarget } + /** @returns {void} */ start() { if (!this.started) { this.eventTarget.addEventListener("click", this.clickCaptured, true) @@ -23,6 +22,7 @@ export class LinkClickObserver { } } + /** @returns {void} */ stop() { if (this.started) { this.eventTarget.removeEventListener("click", this.clickCaptured, true) @@ -30,12 +30,33 @@ export class LinkClickObserver { } } + /** + * @default () => { + * this.eventTarget.removeEventListener("click", this.clickBubbled, false) + * this.eventTarget.addEventListener("click", this.clickBubbled, false) + * } + */ clickCaptured = () => { this.eventTarget.removeEventListener("click", this.clickBubbled, false) this.eventTarget.addEventListener("click", this.clickBubbled, false) } - clickBubbled = (event: Event) => { + /** + * @default (event: Event) => { + * if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { + * const target = (event.composedPath && event.composedPath()[0]) || event.target + * const link = this.findLinkFromClickTarget(target) + * if (link && doesNotTargetIFrame(link)) { + * const location = this.getLocationForLink(link) + * if (this.delegate.willFollowLinkToLocation(link, location, event)) { + * event.preventDefault() + * this.delegate.followedLinkToLocation(link, location) + * } + * } + * } + * } + */ + clickBubbled = (event) => { if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { const target = (event.composedPath && event.composedPath()[0]) || event.target const link = this.findLinkFromClickTarget(target) @@ -49,9 +70,12 @@ export class LinkClickObserver { } } - clickEventIsSignificant(event: MouseEvent) { + /** @param {MouseEvent} event + * @returns {boolean} + */ + clickEventIsSignificant(event) { return !( - (event.target && (event.target as any).isContentEditable) || + (event.target && event.target.isContentEditable) || event.defaultPrevented || event.which > 1 || event.altKey || @@ -61,16 +85,25 @@ export class LinkClickObserver { ) } - findLinkFromClickTarget(target: EventTarget | null): HTMLAnchorElement | undefined { - return findClosestRecursively(target as Element, "a[href]:not([target^=_]):not([download])") + /** @param {EventTarget | null} target + * @returns {HTMLAnchorElement | undefined} + */ + findLinkFromClickTarget(target) { + return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") } - getLocationForLink(link: Element): URL { + /** @param {Element} link + * @returns {URL} + */ + getLocationForLink(link) { return expandURL(link.getAttribute("href") || "") } } -function doesNotTargetIFrame(anchor: HTMLAnchorElement): boolean { +/** @param {HTMLAnchorElement} anchor + * @returns {boolean} + */ +function doesNotTargetIFrame(anchor) { if (anchor.hasAttribute("target")) { for (const element of document.getElementsByName(anchor.target)) { if (element instanceof HTMLIFrameElement) return false @@ -81,3 +114,5 @@ function doesNotTargetIFrame(anchor: HTMLAnchorElement): boolean { return true } } + +/** @typedef {Object} LinkClickObserverDelegate */ diff --git a/src/observers/page_observer.ts b/src/observers/page_observer.js similarity index 59% rename from src/observers/page_observer.ts rename to src/observers/page_observer.js index 50032ffd9..bbb7bb16f 100644 --- a/src/observers/page_observer.ts +++ b/src/observers/page_observer.js @@ -1,25 +1,24 @@ -export interface PageObserverDelegate { - pageBecameInteractive(): void - pageLoaded(): void - pageWillUnload(): void -} - -export enum PageStage { - initial, - loading, - interactive, - complete, -} +export var PageStage +;(function (PageStage) { + PageStage[(PageStage["initial"] = 0)] = "initial" + PageStage[(PageStage["loading"] = 1)] = "loading" + PageStage[(PageStage["interactive"] = 2)] = "interactive" + PageStage[(PageStage["complete"] = 3)] = "complete" +})(PageStage || (PageStage = {})) export class PageObserver { - readonly delegate: PageObserverDelegate + /** @readonly */ + delegate = undefined + /** @default PageStage.initial */ stage = PageStage.initial + /** @default false */ started = false - constructor(delegate: PageObserverDelegate) { + constructor(delegate) { this.delegate = delegate } + /** @returns {void} */ start() { if (!this.started) { if (this.stage == PageStage.initial) { @@ -31,6 +30,7 @@ export class PageObserver { } } + /** @returns {void} */ stop() { if (this.started) { document.removeEventListener("readystatechange", this.interpretReadyState, false) @@ -39,6 +39,16 @@ export class PageObserver { } } + /** + * @default () => { + * const { readyState } = this + * if (readyState == "interactive") { + * this.pageIsInteractive() + * } else if (readyState == "complete") { + * this.pageIsComplete() + * } + * } + */ interpretReadyState = () => { const { readyState } = this if (readyState == "interactive") { @@ -48,6 +58,7 @@ export class PageObserver { } } + /** @returns {void} */ pageIsInteractive() { if (this.stage == PageStage.loading) { this.stage = PageStage.interactive @@ -55,6 +66,7 @@ export class PageObserver { } } + /** @returns {void} */ pageIsComplete() { this.pageIsInteractive() if (this.stage == PageStage.interactive) { @@ -63,6 +75,11 @@ export class PageObserver { } } + /** + * @default () => { + * this.delegate.pageWillUnload() + * } + */ pageWillUnload = () => { this.delegate.pageWillUnload() } @@ -71,3 +88,5 @@ export class PageObserver { return document.readyState } } + +/** @typedef {Object} PageObserverDelegate */ diff --git a/src/observers/scroll_observer.ts b/src/observers/scroll_observer.js similarity index 57% rename from src/observers/scroll_observer.ts rename to src/observers/scroll_observer.js index c754baf47..762ca2a02 100644 --- a/src/observers/scroll_observer.ts +++ b/src/observers/scroll_observer.js @@ -1,17 +1,14 @@ -import { Position } from "../core/types" - -export interface ScrollObserverDelegate { - scrollPositionChanged(position: Position): void -} - export class ScrollObserver { - readonly delegate: ScrollObserverDelegate + /** @readonly */ + delegate = undefined + /** @default false */ started = false - constructor(delegate: ScrollObserverDelegate) { + constructor(delegate) { this.delegate = delegate } + /** @returns {void} */ start() { if (!this.started) { addEventListener("scroll", this.onScroll, false) @@ -20,6 +17,7 @@ export class ScrollObserver { } } + /** @returns {void} */ stop() { if (this.started) { removeEventListener("scroll", this.onScroll, false) @@ -27,13 +25,23 @@ export class ScrollObserver { } } + /** + * @default () => { + * this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }) + * } + */ onScroll = () => { this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }) } // Private - updatePosition(position: Position) { + /** @param {Position} position + * @returns {void} + */ + updatePosition(position) { this.delegate.scrollPositionChanged(position) } } + +/** @typedef {Object} ScrollObserverDelegate */ diff --git a/src/observers/stream_observer.ts b/src/observers/stream_observer.js similarity index 50% rename from src/observers/stream_observer.ts rename to src/observers/stream_observer.js index 2b7f38056..13d9a5a85 100644 --- a/src/observers/stream_observer.ts +++ b/src/observers/stream_observer.js @@ -1,21 +1,23 @@ -import { TurboBeforeFetchResponseEvent } from "../http/fetch_request" import { FetchResponse } from "../http/fetch_response" import { StreamMessage } from "../core/streams/stream_message" -import { StreamSource } from "../core/types" - -export interface StreamObserverDelegate { - receivedMessageFromStream(message: StreamMessage): void -} export class StreamObserver { - readonly delegate: StreamObserverDelegate - readonly sources: Set = new Set() - private started = false + /** @readonly */ + delegate = undefined + /** @readonly + * @default new Set() + */ + sources = new Set() + /** @private + * @default false + */ + started = false - constructor(delegate: StreamObserverDelegate) { + constructor(delegate) { this.delegate = delegate } + /** @returns {void} */ start() { if (!this.started) { this.started = true @@ -23,6 +25,7 @@ export class StreamObserver { } } + /** @returns {void} */ stop() { if (this.started) { this.started = false @@ -30,58 +33,97 @@ export class StreamObserver { } } - connectStreamSource(source: StreamSource) { + /** @param {StreamSource} source + * @returns {void} + */ + connectStreamSource(source) { if (!this.streamSourceIsConnected(source)) { this.sources.add(source) source.addEventListener("message", this.receiveMessageEvent, false) } } - disconnectStreamSource(source: StreamSource) { + /** @param {StreamSource} source + * @returns {void} + */ + disconnectStreamSource(source) { if (this.streamSourceIsConnected(source)) { this.sources.delete(source) source.removeEventListener("message", this.receiveMessageEvent, false) } } - streamSourceIsConnected(source: StreamSource) { + /** @param {StreamSource} source + * @returns {boolean} + */ + streamSourceIsConnected(source) { return this.sources.has(source) } - inspectFetchResponse = ((event: TurboBeforeFetchResponseEvent) => { + /** + * @default ((event: TurboBeforeFetchResponseEvent) => { + * const response = fetchResponseFromEvent(event) + * if (response && fetchResponseIsStream(response)) { + * event.preventDefault() + * this.receiveMessageResponse(response) + * } + * }) + */ + inspectFetchResponse = (event) => { const response = fetchResponseFromEvent(event) if (response && fetchResponseIsStream(response)) { event.preventDefault() this.receiveMessageResponse(response) } - }) + } - receiveMessageEvent = (event: MessageEvent) => { + /** + * @default (event: MessageEvent) => { + * if (this.started && typeof event.data == "string") { + * this.receiveMessageHTML(event.data) + * } + * } + */ + receiveMessageEvent = (event) => { if (this.started && typeof event.data == "string") { this.receiveMessageHTML(event.data) } } - async receiveMessageResponse(response: FetchResponse) { + /** @param {FetchResponse} response + * @returns {Promise} + */ + async receiveMessageResponse(response) { const html = await response.responseHTML if (html) { this.receiveMessageHTML(html) } } - receiveMessageHTML(html: string) { + /** @param {string} html + * @returns {void} + */ + receiveMessageHTML(html) { this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)) } } -function fetchResponseFromEvent(event: TurboBeforeFetchResponseEvent) { +/** @param {TurboBeforeFetchResponseEvent} event + * @returns {any} + */ +function fetchResponseFromEvent(event) { const fetchResponse = event.detail?.fetchResponse if (fetchResponse instanceof FetchResponse) { return fetchResponse } } -function fetchResponseIsStream(response: FetchResponse) { +/** @param {FetchResponse} response + * @returns {any} + */ +function fetchResponseIsStream(response) { const contentType = response.contentType ?? "" return contentType.startsWith(StreamMessage.contentType) } + +/** @typedef {Object} StreamObserverDelegate */ diff --git a/src/polyfills/custom-elements-native-shim.ts b/src/polyfills/custom-elements-native-shim.js similarity index 88% rename from src/polyfills/custom-elements-native-shim.ts rename to src/polyfills/custom-elements-native-shim.js index 8bde413c3..4697943c2 100644 --- a/src/polyfills/custom-elements-native-shim.ts +++ b/src/polyfills/custom-elements-native-shim.js @@ -23,7 +23,7 @@ window.customElements === undefined || // The webcomponentsjs custom elements polyfill doesn't require // ES2015-compatible construction (`super()` or `Reflect.construct`). - (window.customElements as any).polyfillWrapFlushCallback + window.customElements.polyfillWrapFlushCallback ) { return } @@ -36,15 +36,13 @@ * which is enough for the JS VM to correctly set Function.prototype.name. */ const wrapperForTheName = { - HTMLElement: function HTMLElement(this: HTMLElement) { + HTMLElement: function HTMLElement() { return Reflect.construct(BuiltInHTMLElement, [], this.constructor) }, } - window.HTMLElement = wrapperForTheName["HTMLElement"] as unknown as typeof HTMLElement + window.HTMLElement = wrapperForTheName["HTMLElement"] HTMLElement.prototype = BuiltInHTMLElement.prototype HTMLElement.prototype.constructor = HTMLElement Object.setPrototypeOf(HTMLElement, BuiltInHTMLElement) })() - -// Required with --isolatedModules export {} diff --git a/src/polyfills/form-request-submit-polyfill.js b/src/polyfills/form-request-submit-polyfill.js index e5bc6a331..769a59068 100644 --- a/src/polyfills/form-request-submit-polyfill.js +++ b/src/polyfills/form-request-submit-polyfill.js @@ -1,18 +1,18 @@ /** * The MIT License (MIT) - * + * * Copyright (c) 2019 Javan Makhmali - * + * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: - * + * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -22,10 +22,10 @@ * THE SOFTWARE. */ -(function(prototype) { +;(function (prototype) { if (typeof prototype.requestSubmit == "function") return - prototype.requestSubmit = function(submitter) { + prototype.requestSubmit = function (submitter) { if (submitter) { validateSubmitter(submitter, this) submitter.click() @@ -42,10 +42,11 @@ function validateSubmitter(submitter, form) { submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'") submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button") - submitter.form == form || raise(DOMException, "The specified element is not owned by this form element", "NotFoundError") + submitter.form == form || + raise(DOMException, "The specified element is not owned by this form element", "NotFoundError") } function raise(errorConstructor, message, name) { throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name) } -})(HTMLFormElement.prototype); +})(HTMLFormElement.prototype) diff --git a/src/polyfills/index.ts b/src/polyfills/index.js similarity index 100% rename from src/polyfills/index.ts rename to src/polyfills/index.js diff --git a/src/polyfills/submit-event.ts b/src/polyfills/submit-event.js similarity index 71% rename from src/polyfills/submit-event.ts rename to src/polyfills/submit-event.js index 2f246163d..6c991361b 100644 --- a/src/polyfills/submit-event.ts +++ b/src/polyfills/submit-event.js @@ -1,14 +1,18 @@ -type FormSubmitter = HTMLElement & { form?: HTMLFormElement; type?: string } +const submittersByForm = new WeakMap() -const submittersByForm: WeakMap = new WeakMap() - -function findSubmitterFromClickTarget(target: EventTarget | null): FormSubmitter | null { +/** @param {EventTarget | null} target + * @returns {FormSubmitter | null} + */ +function findSubmitterFromClickTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null - const candidate = element ? (element.closest("input, button") as FormSubmitter | null) : null + const candidate = element ? element.closest("input, button") : null return candidate?.type == "submit" ? candidate : null } -function clickCaptured(event: Event) { +/** @param {Event} event + * @returns {void} + */ +function clickCaptured(event) { const submitter = findSubmitterFromClickTarget(event.target) if (submitter && submitter.form) { @@ -36,13 +40,13 @@ function clickCaptured(event: Event) { addEventListener("click", clickCaptured, true) Object.defineProperty(prototype, "submitter", { - get(): HTMLElement | undefined { + get() { if (this.type == "submit" && this.target instanceof HTMLFormElement) { return submittersByForm.get(this.target) } }, }) })() - -// Ensure TypeScript parses this file as a module export {} + +/** @typedef {HTMLElement & { form?: HTMLFormElement; type?: string }} FormSubmitter */ diff --git a/src/script_warning.ts b/src/script_warning.js similarity index 93% rename from src/script_warning.ts rename to src/script_warning.js index f93763c74..6e45c8e25 100644 --- a/src/script_warning.ts +++ b/src/script_warning.js @@ -1,6 +1,6 @@ import { unindent } from "./util" ;(() => { - let element: Element | null = document.currentScript + let element = document.currentScript if (!element) return if (element.hasAttribute("data-turbo-suppress-warning")) return diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index e409e4514..caac97841 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -1,4 +1,4 @@ -(function(eventNames) { +;(function (eventNames) { function serializeToChannel(object, visited = new Set()) { const returned = {} @@ -10,7 +10,7 @@ } else if (value instanceof Element) { returned[key] = value.outerHTML } else if (typeof value == "object") { - if (visited.has(value)) { + if (visited.has(value)) { returned[key] = "skipped to prevent infinitely recursing" } else { visited.add(value) @@ -39,24 +39,28 @@ } window.mutationLogs = [] - new MutationObserver((mutations) => { - for (const { attributeName, target } of mutations.filter(({ type }) => type == "attributes")) { - if (target instanceof Element) { - mutationLogs.push([attributeName, target.id, target.getAttribute(attributeName)]) - } - } - }).observe(document, { subtree: true, childList: true, attributes: true }) + new MutationObserver((mutations) => { + for (const { attributeName, target } of mutations.filter(({ type }) => type == "attributes")) { + if (target instanceof Element) { + mutationLogs.push([attributeName, target.id, target.getAttribute(attributeName)]) + } + } + }).observe(document, { subtree: true, childList: true, attributes: true }) window.bodyMutationLogs = [] - addEventListener("turbo:load", () => { - new MutationObserver((mutations) => { - for (const { addedNodes } of mutations) { - for (const { localName, outerHTML } of addedNodes) { - if (localName == "body") bodyMutationLogs.push([outerHTML]) + addEventListener( + "turbo:load", + () => { + new MutationObserver((mutations) => { + for (const { addedNodes } of mutations) { + for (const { localName, outerHTML } of addedNodes) { + if (localName == "body") bodyMutationLogs.push([outerHTML]) + } } - } - }).observe(document.documentElement, { childList: true }) - }, { once: true }) + }).observe(document.documentElement, { childList: true }) + }, + { once: true } + ) })([ "turbo:click", "turbo:before-stream-render", @@ -75,44 +79,53 @@ "turbo:frame-load", "turbo:frame-render", "turbo:frame-missing", - "turbo:reload" + "turbo:reload", ]) -customElements.define('custom-link-element', class extends HTMLElement { - constructor() { - super() - this.attachShadow({ mode: 'open' }) - } - connectedCallback() { - this.shadowRoot.innerHTML = ` - - ${this.getAttribute('text') || ``} +customElements.define( + "custom-link-element", + class extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: "open" }) + } + connectedCallback() { + this.shadowRoot.innerHTML = ` + + ${this.getAttribute("text") || ``} ` + } } -}) +) -customElements.define('custom-button', class extends HTMLElement { - constructor() { - super() - this.attachShadow({ mode: 'open' }).innerHTML = ` +customElements.define( + "custom-button", + class extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: "open" }).innerHTML = ` Drive in Shadow DOM ` + } } -}) +) -customElements.define('turbo-toggle', class extends HTMLElement { - constructor() { - super() - this.attachShadow({ mode: 'open' }) - } - connectedCallback() { - this.shadowRoot.innerHTML = ` -
+customElements.define( + "turbo-toggle", + class extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: "open" }) + } + connectedCallback() { + this.shadowRoot.innerHTML = ` +
` + } } -}) +) diff --git a/src/tests/functional/async_script_tests.ts b/src/tests/functional/async_script_tests.js similarity index 100% rename from src/tests/functional/async_script_tests.ts rename to src/tests/functional/async_script_tests.js diff --git a/src/tests/functional/autofocus_tests.ts b/src/tests/functional/autofocus_tests.js similarity index 100% rename from src/tests/functional/autofocus_tests.ts rename to src/tests/functional/autofocus_tests.js diff --git a/src/tests/functional/cache_observer_tests.ts b/src/tests/functional/cache_observer_tests.js similarity index 100% rename from src/tests/functional/cache_observer_tests.ts rename to src/tests/functional/cache_observer_tests.js diff --git a/src/tests/functional/drive_disabled_tests.ts b/src/tests/functional/drive_disabled_tests.js similarity index 100% rename from src/tests/functional/drive_disabled_tests.ts rename to src/tests/functional/drive_disabled_tests.js diff --git a/src/tests/functional/drive_tests.ts b/src/tests/functional/drive_tests.js similarity index 100% rename from src/tests/functional/drive_tests.ts rename to src/tests/functional/drive_tests.js diff --git a/src/tests/functional/drive_view_transition_tests.ts b/src/tests/functional/drive_view_transition_tests.js similarity index 100% rename from src/tests/functional/drive_view_transition_tests.ts rename to src/tests/functional/drive_view_transition_tests.js diff --git a/src/tests/functional/form_mode_tests.ts b/src/tests/functional/form_mode_tests.js similarity index 90% rename from src/tests/functional/form_mode_tests.ts rename to src/tests/functional/form_mode_tests.js index 7146b6818..8560f9ed7 100644 --- a/src/tests/functional/form_mode_tests.ts +++ b/src/tests/functional/form_mode_tests.js @@ -1,4 +1,4 @@ -import { Page, test } from "@playwright/test" +import { test } from "@playwright/test" import { getFromLocalStorage, setLocalStorageFromEvent } from "../helpers/page" import { assert } from "chai" @@ -65,11 +65,18 @@ test("test form submission with form mode optin and form enabled from submitter assert.ok(await formSubmitStarted(page)) }) -async function gotoPageWithFormMode(page: Page, formMode: "on" | "off" | "optin") { +/** @param {Page} page + * @param {"on" | "off" | "optin"} formMode + * @returns {Promise} + */ +async function gotoPageWithFormMode(page, formMode) { await page.goto(`/src/tests/fixtures/form_mode.html?formMode=${formMode}`) await setLocalStorageFromEvent(page, "turbo:submit-start", "formSubmitStarted", "true") } -function formSubmitStarted(page: Page) { +/** @param {Page} page + * @returns {any} + */ +function formSubmitStarted(page) { return getFromLocalStorage(page, "formSubmitStarted") } diff --git a/src/tests/functional/form_submission_tests.ts b/src/tests/functional/form_submission_tests.js similarity index 99% rename from src/tests/functional/form_submission_tests.ts rename to src/tests/functional/form_submission_tests.js index 242f10e3e..8af3ec45e 100644 --- a/src/tests/functional/form_submission_tests.ts +++ b/src/tests/functional/form_submission_tests.js @@ -1,4 +1,4 @@ -import { Page, test } from "@playwright/test" +import { test } from "@playwright/test" import { assert } from "chai" import { getFromLocalStorage, @@ -231,7 +231,7 @@ test("test standard GET form submission", async ({ page }) => { test("test standard GET HTMLFormElement.requestSubmit() with Turbo Action", async ({ page }) => { await page.evaluate(() => { - const formControl = document.querySelector("#external-select") + const formControl = document.querySelector("#external-select") if (formControl && formControl.form) formControl.form.requestSubmit() }) @@ -1164,10 +1164,16 @@ test("test form submission skipped with submitter button[formtarget]", async ({ assert.notOk(await formSubmitEnded(page)) }) -function formSubmitStarted(page: Page) { +/** @param {Page} page + * @returns {any} + */ +function formSubmitStarted(page) { return getFromLocalStorage(page, "formSubmitStarted") } -function formSubmitEnded(page: Page) { +/** @param {Page} page + * @returns {any} + */ +function formSubmitEnded(page) { return getFromLocalStorage(page, "formSubmitEnded") } diff --git a/src/tests/functional/frame_navigation_tests.ts b/src/tests/functional/frame_navigation_tests.js similarity index 100% rename from src/tests/functional/frame_navigation_tests.ts rename to src/tests/functional/frame_navigation_tests.js diff --git a/src/tests/functional/frame_tests.ts b/src/tests/functional/frame_tests.js similarity index 95% rename from src/tests/functional/frame_tests.ts rename to src/tests/functional/frame_tests.js index 7d5fe2c85..87a0bdfe0 100644 --- a/src/tests/functional/frame_tests.ts +++ b/src/tests/functional/frame_tests.js @@ -1,4 +1,4 @@ -import { Page, test, expect } from "@playwright/test" +import { test, expect } from "@playwright/test" import { assert, Assertion } from "chai" import { attributeForSelector, @@ -19,15 +19,7 @@ import { searchParams, } from "../helpers/page" -declare global { - namespace Chai { - interface AssertStatic { - equalIgnoringWhitespace(actual: string | null | undefined, expected: string, message?: string): void - } - } -} - -assert.equalIgnoringWhitespace = function (actual: string | null | undefined, expected: string, message?: string) { +assert.equalIgnoringWhitespace = function (actual, expected, message) { new Assertion(actual?.trim()).to.equal(expected.trim(), message) } @@ -153,7 +145,7 @@ test("successfully following a link to a page without a matching frame dispatche test("successfully following a link to a page without a matching frame shows an error and throws an exception", async ({ page, }) => { - let error: Error | undefined = undefined + let error = undefined page.once("pageerror", (e) => (error = e)) await page.click("#missing-frame-link") @@ -161,7 +153,7 @@ test("successfully following a link to a page without a matching frame shows an assert.match(await page.innerText("#missing"), /Content missing/) assert.exists(error) - assert.include(error!.message, `The response (200) did not contain the expected `) + assert.include(error.message, `The response (200) did not contain the expected `) }) test("successfully following a link to a page with `turbo-visit-control` `reload` performs a full page reload", async ({ @@ -185,7 +177,7 @@ test("failing to follow a link to a page without a matching frame dispatches a t test("failing to follow a link to a page without a matching frame shows an error and throws an exception", async ({ page, }) => { - let error: Error | undefined = undefined + let error = undefined page.once("pageerror", (e) => (error = e)) await page.click("#missing-page-link") @@ -193,7 +185,7 @@ test("failing to follow a link to a page without a matching frame shows an error assert.match(await page.innerText("#missing"), /Content missing/) assert.exists(error) - assert.include(error!.message, `The response (404) did not contain the expected `) + assert.include(error.message, `The response (404) did not contain the expected `) }) test("test the turbo:frame-missing event following a link to a page without a matching frame can be handled", async ({ @@ -202,12 +194,12 @@ test("test the turbo:frame-missing event following a link to a page without a ma await page.locator("#missing").evaluate((frame) => { frame.addEventListener( "turbo:frame-missing", - ((event) => { + (event) => { if (event.target instanceof Element) { event.preventDefault() event.target.textContent = "Overridden" } - }) as EventListener, + }, { once: true } ) }) @@ -223,12 +215,12 @@ test("test the turbo:frame-missing event following a link to a page without a ma await page.locator("#missing").evaluate((frame) => { frame.addEventListener( "turbo:frame-missing", - ((event: CustomEvent) => { + (event) => { event.preventDefault() const { response, visit } = event.detail visit(response) - }) as EventListener, + }, { once: true } ) }) @@ -513,7 +505,7 @@ test("test navigating a frame targeting _top from an outer link fires events", a test("test invoking .reload() re-fetches the frame's content", async ({ page }) => { await page.click("#link-frame") await nextEventOnTarget(page, "frame", "turbo:frame-load") - await page.evaluate(() => (document.getElementById("frame") as any).reload()) + await page.evaluate(() => document.getElementById("frame").reload()) const dispatchedEvents = await readEventLogs(page) @@ -879,7 +871,7 @@ test("test navigating a eager frame with a link[method=get] that does not fetch test("form submissions from frames clear snapshot cache", async ({ page }) => { await page.evaluate(() => { - document.querySelector("h1")!.textContent = "Changed" + document.querySelector("h1").textContent = "Changed" }) await expect(page.locator("h1")).toHaveText("Changed") @@ -893,31 +885,27 @@ test("form submissions from frames clear snapshot cache", async ({ page }) => { await expect(page.locator("h1")).not.toHaveText("Changed") }) -async function withoutChangingEventListenersCount(page: Page, callback: () => Promise) { +/** @param {Page} page + * @param {() => Promise} callback + * @returns {Promise} + */ +async function withoutChangingEventListenersCount(page, callback) { const name = "eventListenersAttachedToDocument" const setup = () => { return page.evaluate((name) => { - const context = window as any + const context = window context[name] = 0 context.originals = { addEventListener: document.addEventListener, removeEventListener: document.removeEventListener, } - document.addEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ) => { + document.addEventListener = (type, listener, options) => { context.originals.addEventListener.call(document, type, listener, options) context[name] += 1 } - document.removeEventListener = ( - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ) => { + document.removeEventListener = (type, listener, options) => { context.originals.removeEventListener.call(document, type, listener, options) context[name] -= 1 } @@ -928,7 +916,7 @@ async function withoutChangingEventListenersCount(page: Page, callback: () => Pr const teardown = () => { return page.evaluate((name) => { - const context = window as any + const context = window const { addEventListener, removeEventListener } = context.originals document.addEventListener = addEventListener @@ -945,12 +933,9 @@ async function withoutChangingEventListenersCount(page: Page, callback: () => Pr assert.equal(finalCount, originalCount, "expected callback not to leak event listeners") } -function frameScriptEvaluationCount(page: Page): Promise { +/** @param {Page} page + * @returns {Promise} + */ +function frameScriptEvaluationCount(page) { return page.evaluate(() => window.frameScriptEvaluationCount) } - -declare global { - interface Window { - frameScriptEvaluationCount?: number - } -} diff --git a/src/tests/functional/import_tests.ts b/src/tests/functional/import_tests.js similarity index 100% rename from src/tests/functional/import_tests.ts rename to src/tests/functional/import_tests.js diff --git a/src/tests/functional/loading_tests.ts b/src/tests/functional/loading_tests.js similarity index 97% rename from src/tests/functional/loading_tests.ts rename to src/tests/functional/loading_tests.js index e445775dc..cbf3d2402 100644 --- a/src/tests/functional/loading_tests.ts +++ b/src/tests/functional/loading_tests.js @@ -12,12 +12,6 @@ import { readEventLogs, } from "../helpers/page" -declare global { - interface Window { - savedElement: Element | null - } -} - test.beforeEach(async ({ page }) => { await page.goto("/src/tests/fixtures/loading.html") await readEventLogs(page) @@ -111,7 +105,7 @@ test("test reloading a frame reloads the content", async ({ page }) => { assert.ok(await hasSelector(page, frameContent)) assert.equal(await nextAttributeMutationNamed(page, "frame", "complete"), "", "has [complete] attribute") - await page.evaluate(() => (document.querySelector("#loading-eager turbo-frame") as any)?.reload()) + await page.evaluate(() => document.querySelector("#loading-eager turbo-frame")?.reload()) assert.ok(await hasSelector(page, frameContent)) assert.equal(await nextAttributeMutationNamed(page, "frame", "complete"), null, "clears [complete] attribute") }) diff --git a/src/tests/functional/navigation_tests.ts b/src/tests/functional/navigation_tests.js similarity index 99% rename from src/tests/functional/navigation_tests.ts rename to src/tests/functional/navigation_tests.js index e2131e6ee..823ea3ec1 100644 --- a/src/tests/functional/navigation_tests.ts +++ b/src/tests/functional/navigation_tests.js @@ -261,7 +261,7 @@ test("test following a same-origin [target] link", async ({ page }) => { }) test("test following a same-origin [download] link", async ({ page }) => { - assert.notOk( + assert.notOk( await willChangeBody(page, async () => { await page.click("#same-origin-download-link") await nextBeat() @@ -310,7 +310,7 @@ test("test link targeting a disabled turbo-frame navigates the page", async ({ p }) test("test skip link with hash-only path scrolls to the anchor without a visit", async ({ page }) => { - assert.notOk( + assert.notOk( await willChangeBody(page, async () => { await page.click('a[href="#main"]') await nextBeat() diff --git a/src/tests/functional/pausable_rendering_tests.ts b/src/tests/functional/pausable_rendering_tests.js similarity index 100% rename from src/tests/functional/pausable_rendering_tests.ts rename to src/tests/functional/pausable_rendering_tests.js diff --git a/src/tests/functional/pausable_requests_tests.ts b/src/tests/functional/pausable_requests_tests.js similarity index 100% rename from src/tests/functional/pausable_requests_tests.ts rename to src/tests/functional/pausable_requests_tests.js diff --git a/src/tests/functional/preloader_tests.ts b/src/tests/functional/preloader_tests.js similarity index 100% rename from src/tests/functional/preloader_tests.ts rename to src/tests/functional/preloader_tests.js diff --git a/src/tests/functional/rendering_tests.ts b/src/tests/functional/rendering_tests.js similarity index 92% rename from src/tests/functional/rendering_tests.ts rename to src/tests/functional/rendering_tests.js index bb8e33885..d08be8868 100644 --- a/src/tests/functional/rendering_tests.ts +++ b/src/tests/functional/rendering_tests.js @@ -1,4 +1,4 @@ -import { JSHandle, Page, test } from "@playwright/test" +import { test } from "@playwright/test" import { assert } from "chai" import { clearLocalStorage, @@ -62,7 +62,7 @@ test("test reloads when tracked elements change", async ({ page }) => { await page.evaluate(() => window.addEventListener( "turbo:reload", - (e: any) => { + (e) => { localStorage.setItem("reloadReason", e.detail.reason) }, { once: true } @@ -86,7 +86,7 @@ test("test reloads when tracked elements change due to failed form submission", await page.evaluate(() => { window.addEventListener( "turbo:reload", - (e: any) => { + (e) => { localStorage.setItem("reason", e.detail.reason) }, { once: true } @@ -116,9 +116,9 @@ test("test reloads when tracked elements change due to failed form submission", test("test before-render event supports custom render function", async ({ page }) => { await page.evaluate(() => addEventListener("turbo:before-render", (event) => { - const { detail } = event as CustomEvent + const { detail } = event const { render } = detail - detail.render = (currentElement: HTMLBodyElement, newElement: HTMLBodyElement) => { + detail.render = (currentElement, newElement) => { newElement.insertAdjacentHTML("beforeend", `Custom Rendered`) render(currentElement, newElement) } @@ -134,14 +134,14 @@ test("test before-render event supports custom render function", async ({ page } test("test before-render event supports async custom render function", async ({ page }) => { await page.evaluate(() => { const nextEventLoopTick = () => - new Promise((resolve) => { + new Promise((resolve) => { setTimeout(() => resolve(), 0) }) addEventListener("turbo:before-render", (event) => { - const { detail } = event as CustomEvent + const { detail } = event const { render } = detail - detail.render = async (currentElement: HTMLBodyElement, newElement: HTMLBodyElement) => { + detail.render = async (currentElement, newElement) => { await nextEventLoopTick() newElement.insertAdjacentHTML("beforeend", `Custom Rendered`) @@ -173,7 +173,7 @@ test("test reloads when turbo-visit-control setting is reload", async ({ page }) await page.evaluate(() => window.addEventListener( "turbo:reload", - (e: any) => { + (e) => { localStorage.setItem("reloadReason", e.detail.reason) }, { once: true } @@ -269,7 +269,7 @@ test("test does not evaluate head stylesheet elements inside noscript elements", }) test("test waits for CSS to be loaded before rendering", async ({ page }) => { - let finishLoadingCSS = (_value?: unknown) => {} + let finishLoadingCSS = (_value) => {} const promise = new Promise((resolve) => { finishLoadingCSS = resolve }) @@ -292,7 +292,7 @@ test("test waits for CSS to be loaded before rendering", async ({ page }) => { }) test("test waits for CSS to fail before rendering", async ({ page }) => { - let finishLoadingCSS = (_value?: unknown) => {} + let finishLoadingCSS = (_value) => {} const promise = new Promise((resolve) => { finishLoadingCSS = resolve }) @@ -315,7 +315,7 @@ test("test waits for CSS to fail before rendering", async ({ page }) => { }) test("test waits for some time, but renders if CSS takes too much to load", async ({ page }) => { - let finishLoadingCSS = (_value?: unknown) => {} + let finishLoadingCSS = (_value) => {} const promise = new Promise((resolve) => { finishLoadingCSS = resolve }) @@ -381,7 +381,7 @@ test("test preserves permanent elements", async ({ page }) => { await page.click("#permanent-element-link") await nextEventNamed(page, "turbo:render") assert.ok(await strictElementEquals(permanentElement, await page.locator("#permanent"))) - assert.equal(await permanentElement!.textContent(), "Rendering") + assert.equal(await permanentElement.textContent(), "Rendering") await page.goBack() await nextEventNamed(page, "turbo:render") @@ -408,9 +408,9 @@ test("test before-frame-render event supports custom render function within turb const frame = await page.locator("#frame") await frame.evaluate((frame) => frame.addEventListener("turbo:before-frame-render", (event) => { - const { detail } = event as CustomEvent + const { detail } = event const { render } = detail - detail.render = (currentElement: Element, newElement: Element) => { + detail.render = (currentElement, newElement) => { newElement.insertAdjacentHTML("beforeend", `Custom Rendered Frame`) render(currentElement, newElement) } @@ -463,13 +463,13 @@ test("test preserves permanent element video playback", async ({ page }) => { await page.click("#permanent-video-button") await sleep(500) - const timeBeforeRender = await videoElement.evaluate((video: HTMLVideoElement) => video.currentTime) + const timeBeforeRender = await videoElement.evaluate((video) => video.currentTime) assert.notEqual(timeBeforeRender, 0, "playback has started") await page.click("#permanent-element-link") await nextBody(page) - const timeAfterRender = await videoElement.evaluate((video: HTMLVideoElement) => video.currentTime) + const timeAfterRender = await videoElement.evaluate((video) => video.currentTime) assert.equal(timeAfterRender, timeBeforeRender, "element state is preserved") }) @@ -591,38 +591,54 @@ test("test rendering a redirect response replaces the body once and only once", assert.ok(await noNextBodyMutation(page), "replaces element once") }) -function deepElementsEqual( - page: Page, - left: JSHandle[], - right: JSHandle[] -): Promise { +/** @param {Page} page + * @param {JSHandle[]} left + * @param {JSHandle[]} right + * @returns {Promise} + */ +function deepElementsEqual(page, left, right) { return page.evaluate( ([left, right]) => left.length == right.length && left.every((element) => right.includes(element)), [left, right] ) } -function headScriptEvaluationCount(page: Page): Promise { +/** @param {Page} page + * @returns {Promise} + */ +function headScriptEvaluationCount(page) { return page.evaluate(() => window.headScriptEvaluationCount) } -function bodyScriptEvaluationCount(page: Page): Promise { +/** @param {Page} page + * @returns {Promise} + */ +function bodyScriptEvaluationCount(page) { return page.evaluate(() => window.bodyScriptEvaluationCount) } -function isStylesheetEvaluated(page: Page): Promise { +/** @param {Page} page + * @returns {Promise} + */ +function isStylesheetEvaluated(page) { return page.evaluate( () => getComputedStyle(document.body).getPropertyValue("--black-if-evaluated").trim() === "black" ) } -function isNoscriptStylesheetEvaluated(page: Page): Promise { +/** @param {Page} page + * @returns {Promise} + */ +function isNoscriptStylesheetEvaluated(page) { return page.evaluate( () => getComputedStyle(document.body).getPropertyValue("--black-if-noscript-evaluated").trim() === "black" ) } -function modifyBodyAfterRemoval(page: Page) { +/** @param {Page} page + * @returns {any} + */ +function modifyBodyAfterRemoval(page) { return page.evaluate(() => { const { documentElement, body } = document const observer = new MutationObserver((records) => { @@ -637,10 +653,3 @@ function modifyBodyAfterRemoval(page: Page) { observer.observe(documentElement, { childList: true }) }) } - -declare global { - interface Window { - headScriptEvaluationCount?: number - bodyScriptEvaluationCount?: number - } -} diff --git a/src/tests/functional/scroll_restoration_tests.ts b/src/tests/functional/scroll_restoration_tests.js similarity index 100% rename from src/tests/functional/scroll_restoration_tests.ts rename to src/tests/functional/scroll_restoration_tests.js diff --git a/src/tests/functional/stream_tests.ts b/src/tests/functional/stream_tests.js similarity index 93% rename from src/tests/functional/stream_tests.ts rename to src/tests/functional/stream_tests.js index 3d159b2e3..0d292edfc 100644 --- a/src/tests/functional/stream_tests.ts +++ b/src/tests/functional/stream_tests.js @@ -74,18 +74,18 @@ test("test overriding with custom StreamActions", async ({ page }) => { const html = "Rendered with Custom Action" await page.evaluate((html) => { - const CustomActions: Record = { - customUpdate(newStream: { targetElements: HTMLElement[] }) { + const CustomActions = { + customUpdate(newStream) { for (const target of newStream.targetElements) target.innerHTML = html }, } - addEventListener("turbo:before-stream-render", (({ target, detail }: CustomEvent) => { - const stream = target as unknown as { action: string } + addEventListener("turbo:before-stream-render", ({ target, detail }) => { + const stream = target const defaultRender = detail.render detail.render = CustomActions[stream.action] || defaultRender - }) as EventListener) + }) window.Turbo.renderStreamMessage(` diff --git a/src/tests/functional/visit_tests.ts b/src/tests/functional/visit_tests.js similarity index 91% rename from src/tests/functional/visit_tests.ts rename to src/tests/functional/visit_tests.js index 146bcda58..cfbac88dc 100644 --- a/src/tests/functional/visit_tests.ts +++ b/src/tests/functional/visit_tests.js @@ -1,4 +1,4 @@ -import { Page, test } from "@playwright/test" +import { test } from "@playwright/test" import { assert } from "chai" import { get } from "http" import { @@ -75,7 +75,7 @@ test("test canceling a before-visit event prevents navigation", async ({ page }) await cancelNextVisit(page) const urlBeforeVisit = page.url() - assert.notOk( + assert.notOk( await willChangeBody(page, async () => { await page.click("#same-origin-link") await nextBeat() @@ -118,9 +118,9 @@ test("test turbo:before-fetch-response open new site", async ({ page }) => { page.evaluate(() => addEventListener( "turbo:before-fetch-response", - async function eventListener(event: any) { + async function eventListener(event) { removeEventListener("turbo:before-fetch-response", eventListener, false) - ;(window as any).fetchResponseResult = { + window.fetchResponseResult = { responseText: await event.detail.fetchResponse.responseText, responseHTML: await event.detail.fetchResponse.responseHTML, } @@ -132,7 +132,7 @@ test("test turbo:before-fetch-response open new site", async ({ page }) => { await page.click("#sample-response") await nextEventNamed(page, "turbo:before-fetch-response") - const fetchResponseResult = await page.evaluate(() => (window as any).fetchResponseResult) + const fetchResponseResult = await page.evaluate(() => window.fetchResponseResult) assert.isTrue(fetchResponseResult.responseText.indexOf("An element with an ID") > -1) assert.isTrue(fetchResponseResult.responseHTML.indexOf("An element with an ID") > -1) @@ -172,11 +172,17 @@ test("test cache does not override response after redirect", async ({ page }) => assert.equal(await page.locator("some-cached-element").count(), 0) }) -function cancelNextVisit(page: Page): Promise { +/** @param {Page} page + * @returns {Promise} + */ +function cancelNextVisit(page) { return cancelNextEvent(page, "turbo:before-visit") } -function contentTypeOfURL(url: string): Promise { +/** @param {string} url + * @returns {Promise} + */ +function contentTypeOfURL(url) { return new Promise((resolve) => { get(url, ({ headers }) => resolve(headers["content-type"])) }) @@ -213,13 +219,17 @@ test("test can scroll to element after history-initiated turbo:visit", async ({ test("test Visit with network error", async ({ page }) => { await page.evaluate(() => { - addEventListener("turbo:fetch-request-error", (event: Event) => event.preventDefault()) + addEventListener("turbo:fetch-request-error", (event) => event.preventDefault()) }) await page.context().setOffline(true) await page.click("#same-origin-link") await nextEventNamed(page, "turbo:fetch-request-error") }) -async function visitLocation(page: Page, location: string) { +/** @param {Page} page + * @param {string} location + * @returns {Promise} + */ +async function visitLocation(page, location) { return page.evaluate((location) => window.Turbo.visit(location), location) } diff --git a/src/tests/helpers/dom_test_case.ts b/src/tests/helpers/dom_test_case.js similarity index 64% rename from src/tests/helpers/dom_test_case.ts rename to src/tests/helpers/dom_test_case.js index 238fffe3a..057ae6818 100644 --- a/src/tests/helpers/dom_test_case.ts +++ b/src/tests/helpers/dom_test_case.js @@ -1,21 +1,30 @@ export class DOMTestCase { + /** @default document.createElement("main") */ fixtureElement = document.createElement("main") + /** @returns {Promise} */ async setup() { this.fixtureElement.hidden = true document.body.insertAdjacentElement("afterbegin", this.fixtureElement) } + /** @returns {Promise} */ async teardown() { this.fixtureElement.innerHTML = "" this.fixtureElement.remove() } - append(node: Node) { + /** @param {Node} node + * @returns {void} + */ + append(node) { this.fixtureElement.appendChild(node) } - find(selector: string) { + /** @param {string} selector + * @returns {Element} + */ + find(selector) { return this.fixtureElement.querySelector(selector) } @@ -23,7 +32,7 @@ export class DOMTestCase { return this.fixtureElement.innerHTML } - set fixtureHTML(html: string) { + set fixtureHTML(html) { this.fixtureElement.innerHTML = html } } diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js new file mode 100644 index 000000000..e1c6c1462 --- /dev/null +++ b/src/tests/helpers/page.js @@ -0,0 +1,478 @@ +/** @param {Page} page + * @param {string} selector + * @param {string} attributeName + * @returns {Promise} + */ +export function attributeForSelector(page, selector, attributeName) { + return page.locator(selector).getAttribute(attributeName) +} + +/** @param {Page} page + * @param {CancellableEvent} eventName + * @returns {Promise} + */ +export function cancelNextEvent(page, eventName) { + return page.evaluate( + (eventName) => addEventListener(eventName, (event) => event.preventDefault(), { once: true }), + eventName + ) +} + +/** @param {Page} page + * @param {string} selector + * @returns {any} + */ +export function clickWithoutScrolling(page, selector, options = {}) { + const element = page.locator(selector, options) + + return element.evaluate((element) => element instanceof HTMLElement && element.click()) +} + +/** @param {Page} page + * @returns {Promise} + */ +export function clearLocalStorage(page) { + return page.evaluate(() => localStorage.clear()) +} + +/** @param {...JSHandle} [handles] + * @returns {Promise} + */ +export function disposeAll(...handles) { + return Promise.all(handles.map((handle) => handle.dispose())) +} + +/** @param {Page} page + * @param {string} key + * @returns {any} + */ +export function getFromLocalStorage(page, key) { + return page.evaluate((storageKey) => localStorage.getItem(storageKey), key) +} + +/** @param {string} url + * @param {string} key + * @returns {string | null} + */ +export function getSearchParam(url, key) { + return searchParams(url).get(key) +} + +/** @param {string} url + * @returns {string} + */ +export function hash(url) { + const { hash } = new URL(url) + + return hash +} + +/** @param {Page} page + * @param {string} selector + * @returns {Promise} + */ +export async function hasSelector(page, selector) { + return !!(await page.locator(selector).count()) +} + +/** @param {Page} page + * @param {string} selector + * @returns {Promise} + */ +export function innerHTMLForSelector(page, selector) { + return page.locator(selector).innerHTML() +} + +/** @param {Page} page + * @param {string} selector + * @returns {Promise} + */ +export async function isScrolledToSelector(page, selector) { + const boundingBox = await page + .locator(selector) + .evaluate((element) => (element instanceof HTMLElement ? { x: element.offsetLeft, y: element.offsetTop } : null)) + + if (boundingBox) { + const { y: pageY } = await scrollPosition(page) + const { y: elementY } = boundingBox + const offset = pageY - elementY + return Math.abs(offset) <= 2 + } else { + return false + } +} + +/** @returns {Promise} */ +export function nextBeat() { + return sleep(100) +} + +/** @param {Page} _page + * @returns {Promise} + */ +export function nextBody(_page, timeout = 500) { + return sleep(timeout) +} + +/** @param {Page} page + * @param {string} eventName + * @returns {Promise} + */ +export async function nextEventNamed(page, eventName) { + let record + while (!record) { + const records = await readEventLogs(page, 1) + record = records.find(([name]) => name == eventName) + } + return record[1] +} + +/** @param {Page} page + * @param {string} elementId + * @param {string} eventName + * @returns {Promise} + */ +export async function nextEventOnTarget(page, elementId, eventName) { + let record + while (!record) { + const records = await readEventLogs(page, 1) + record = records.find(([name, _, id]) => name == eventName && id == elementId) + } + return record[1] +} + +/** @param {Page} page + * @param {string} elementId + * @param {string} eventName + * @returns {Promise} + */ +export async function listenForEventOnTarget(page, elementId, eventName) { + return page.locator("#" + elementId).evaluate((element, eventName) => { + const eventLogs = window.eventLogs + + element.addEventListener(eventName, ({ target, type }) => { + if (target instanceof Element) { + eventLogs.push([type, {}, target.id]) + } + }) + }, eventName) +} + +/** @param {Page} page + * @returns {Promise} + */ +export async function nextBodyMutation(page) { + let record + while (!record) { + ;[record] = await readBodyMutationLogs(page, 1) + } + return record[0] +} + +/** @param {Page} page + * @returns {Promise} + */ +export async function noNextBodyMutation(page) { + const records = await readBodyMutationLogs(page, 1) + return !records.some((record) => !!record) +} + +/** @param {Page} page + * @param {string} elementId + * @param {string} attributeName + * @returns {Promise} + */ +export async function nextAttributeMutationNamed(page, elementId, attributeName) { + let record + while (!record) { + const records = await readMutationLogs(page, 1) + record = records.find(([name, id]) => name == attributeName && id == elementId) + } + const attributeValue = record[2] + return attributeValue +} + +/** @param {Page} page + * @param {string} elementId + * @param {string} attributeName + * @returns {Promise} + */ +export async function noNextAttributeMutationNamed(page, elementId, attributeName) { + const records = await readMutationLogs(page, 1) + return !records.some(([name, _, target]) => name == attributeName && target == elementId) +} + +/** @param {Page} page + * @param {string} eventName + * @returns {Promise} + */ +export async function noNextEventNamed(page, eventName) { + const records = await readEventLogs(page, 1) + return !records.some(([name]) => name == eventName) +} + +/** @param {Page} page + * @param {string} elementId + * @param {string} eventName + * @returns {Promise} + */ +export async function noNextEventOnTarget(page, elementId, eventName) { + const records = await readEventLogs(page, 1) + return !records.some(([name, _, target]) => name == eventName && target == elementId) +} + +/** @param {Page} page + * @param {string} selector + * @returns {Promise} + */ +export async function outerHTMLForSelector(page, selector) { + const element = await page.locator(selector) + return element.evaluate((element) => element.outerHTML) +} + +/** @param {string} url + * @returns {string} + */ +export function pathname(url) { + const { pathname } = new URL(url) + + return pathname +} + +/** @param {Page} page + * @param {string} name + * @returns {Promise} + */ +export async function pathnameForIFrame(page, name) { + const locator = await page.locator(`[name="${name}"]`) + const location = await locator.evaluate((iframe) => iframe.contentWindow?.location) + + if (location) { + return pathname(location.href) + } else { + return "" + } +} + +/** @param {Page} page + * @param {string} selector + * @param {string} propertyName + * @returns {Promise} + */ +export function propertyForSelector(page, selector, propertyName) { + return page.locator(selector).evaluate((element, propertyName) => element[propertyName], propertyName) +} + +/** @param {Page} page + * @param {string} identifier + * @param {number} [length] + * @returns {Promise} + */ +async function readArray(page, identifier, length) { + return page.evaluate( + ({ identifier, length }) => { + const records = window[identifier] + if (records != null && typeof records.splice == "function") { + return records.splice(0, typeof length === "undefined" ? records.length : length) + } else { + return [] + } + }, + { identifier, length } + ) +} + +/** @param {Page} page + * @param {number} [length] + * @returns {Promise} + */ +export function readBodyMutationLogs(page, length) { + return readArray(page, "bodyMutationLogs", length) +} + +/** @param {Page} page + * @param {number} [length] + * @returns {Promise} + */ +export function readEventLogs(page, length) { + return readArray(page, "eventLogs", length) +} + +/** @param {Page} page + * @param {number} [length] + * @returns {Promise} + */ +export function readMutationLogs(page, length) { + return readArray(page, "mutationLogs", length) +} + +/** @param {string} url + * @returns {string} + */ +export function search(url) { + const { search } = new URL(url) + + return search +} + +/** @param {string} url + * @returns {URLSearchParams} + */ +export function searchParams(url) { + const { searchParams } = new URL(url) + + return searchParams +} + +/** @param {Page} page + * @param {string} selector + * @returns {Promise} + */ +export function selectorHasFocus(page, selector) { + return page.locator(selector).evaluate((element) => element === document.activeElement) +} + +/** @param {Page} page + * @param {string} eventName + * @param {string} storageKey + * @param {string} storageValue + * @returns {any} + */ +export function setLocalStorageFromEvent(page, eventName, storageKey, storageValue) { + return page.evaluate( + ({ eventName, storageKey, storageValue }) => { + addEventListener(eventName, () => localStorage.setItem(storageKey, storageValue)) + }, + { eventName, storageKey, storageValue } + ) +} + +/** @param {Page} page + * @returns {Promise<{ x: number; y: number }>} + */ +export function scrollPosition(page) { + return page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })) +} + +/** @param {Page} page + * @returns {Promise} + */ +export async function isScrolledToTop(page) { + const { y: pageY } = await scrollPosition(page) + return pageY === 0 +} + +/** @param {Page} page + * @param {string} selector + * @returns {Promise} + */ +export function scrollToSelector(page, selector) { + return page.locator(selector).scrollIntoViewIfNeeded() +} + +/** @returns {Promise} */ +export function sleep(timeout = 0) { + return new Promise((resolve) => setTimeout(() => resolve(undefined), timeout)) +} + +/** @param {Locator} left + * @param {Locator} right + * @returns {Promise} + */ +export async function strictElementEquals(left, right) { + return left.evaluate((left, right) => left === right, await right.elementHandle()) +} + +/** @param {Page} page + * @param {string} html + * @returns {Promise} + */ +export function textContent(page, html) { + return page.evaluate((html) => { + const parser = new DOMParser() + const { documentElement } = parser.parseFromString(html, "text/html") + + return documentElement.textContent + }, html) +} + +/** @param {Page} page + * @returns {Promise} + */ +export function visitAction(page) { + return page.evaluate(() => { + try { + return window.Turbo.navigator.currentVisit.action + } catch (error) { + return "load" + } + }) +} + +/** @param {Page} page + * @param {string} pathname + * @returns {Promise} + */ +export function waitForPathname(page, pathname) { + return page.waitForURL((url) => url.pathname == pathname) +} + +/** @param {Page} page + * @param {string} text + * @param {"visible" | "attached"} [state="visible"] + * @returns {any} + */ +export function waitUntilText(page, text, state = "visible") { + return page.waitForSelector(`text='${text}'`, { state }) +} + +/** @param {Page} page + * @param {string} selector + * @param {"visible" | "attached"} [state="visible"] + * @returns {any} + */ +export function waitUntilSelector(page, selector, state = "visible") { + return page.waitForSelector(selector, { state }) +} + +/** @param {Page} page + * @param {string} selector + * @param {"hidden" | "detached"} [state="hidden"] + * @returns {any} + */ +export function waitUntilNoSelector(page, selector, state = "hidden") { + return page.waitForSelector(selector, { state }) +} + +/** @param {Page} page + * @param {() => Promise} callback + * @returns {Promise} + */ +export async function willChangeBody(page, callback) { + const handles = [] + + try { + const originalBody = await page.evaluateHandle(() => document.body) + handles.push(originalBody) + + await callback() + + const latestBody = await page.evaluateHandle(() => document.body) + handles.push(latestBody) + + return page.evaluate(({ originalBody, latestBody }) => originalBody !== latestBody, { originalBody, latestBody }) + } finally { + disposeAll(...handles) + } +} + +/** @typedef {string | null} Target */ +/** @typedef {string} EventType */ +/** @typedef {any} EventDetail */ +/** @typedef {[EventType, EventDetail, Target]} EventLog */ +/** @typedef {string} MutationAttributeName */ +/** @typedef {string | null} MutationAttributeValue */ +/** @typedef {[MutationAttributeName, Target, MutationAttributeValue]} MutationLog */ +/** @typedef {string} BodyHTML */ +/** @typedef {[BodyHTML]} BodyMutationLog */ +/** @typedef {"turbo:click" | "turbo:before-visit"} CancellableEvent */ diff --git a/src/tests/helpers/page.ts b/src/tests/helpers/page.ts deleted file mode 100644 index 45056406e..000000000 --- a/src/tests/helpers/page.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { JSHandle, Locator, Page } from "@playwright/test" - -type Target = string | null - -type EventType = string -type EventDetail = any -type EventLog = [EventType, EventDetail, Target] - -type MutationAttributeName = string -type MutationAttributeValue = string | null -type MutationLog = [MutationAttributeName, Target, MutationAttributeValue] - -type BodyHTML = string -type BodyMutationLog = [BodyHTML] - -export function attributeForSelector(page: Page, selector: string, attributeName: string): Promise { - return page.locator(selector).getAttribute(attributeName) -} - -type CancellableEvent = "turbo:click" | "turbo:before-visit" - -export function cancelNextEvent(page: Page, eventName: CancellableEvent): Promise { - return page.evaluate( - (eventName) => addEventListener(eventName, (event) => event.preventDefault(), { once: true }), - eventName - ) -} - -export function clickWithoutScrolling(page: Page, selector: string, options = {}) { - const element = page.locator(selector, options) - - return element.evaluate((element) => element instanceof HTMLElement && element.click()) -} - -export function clearLocalStorage(page: Page): Promise { - return page.evaluate(() => localStorage.clear()) -} - -export function disposeAll(...handles: JSHandle[]): Promise { - return Promise.all(handles.map((handle) => handle.dispose())) -} - -export function getFromLocalStorage(page: Page, key: string) { - return page.evaluate((storageKey: string) => localStorage.getItem(storageKey), key) -} - -export function getSearchParam(url: string, key: string): string | null { - return searchParams(url).get(key) -} - -export function hash(url: string): string { - const { hash } = new URL(url) - - return hash -} - -export async function hasSelector(page: Page, selector: string): Promise { - return !!(await page.locator(selector).count()) -} - -export function innerHTMLForSelector(page: Page, selector: string): Promise { - return page.locator(selector).innerHTML() -} - -export async function isScrolledToSelector(page: Page, selector: string): Promise { - const boundingBox = await page - .locator(selector) - .evaluate((element) => (element instanceof HTMLElement ? { x: element.offsetLeft, y: element.offsetTop } : null)) - - if (boundingBox) { - const { y: pageY } = await scrollPosition(page) - const { y: elementY } = boundingBox - const offset = pageY - elementY - return Math.abs(offset) <= 2 - } else { - return false - } -} - -export function nextBeat() { - return sleep(100) -} - -export function nextBody(_page: Page, timeout = 500) { - return sleep(timeout) -} - -export async function nextEventNamed(page: Page, eventName: string): Promise { - let record: EventLog | undefined - while (!record) { - const records = await readEventLogs(page, 1) - record = records.find(([name]) => name == eventName) - } - return record[1] -} - -export async function nextEventOnTarget(page: Page, elementId: string, eventName: string): Promise { - let record: EventLog | undefined - while (!record) { - const records = await readEventLogs(page, 1) - record = records.find(([name, _, id]) => name == eventName && id == elementId) - } - return record[1] -} - -export async function listenForEventOnTarget(page: Page, elementId: string, eventName: string): Promise { - return page.locator("#" + elementId).evaluate((element, eventName) => { - const eventLogs = (window as any).eventLogs - - element.addEventListener(eventName, ({ target, type }) => { - if (target instanceof Element) { - eventLogs.push([type, {}, target.id]) - } - }) - }, eventName) -} - -export async function nextBodyMutation(page: Page): Promise { - let record: BodyMutationLog | undefined - while (!record) { - ;[record] = await readBodyMutationLogs(page, 1) - } - return record[0] -} - -export async function noNextBodyMutation(page: Page): Promise { - const records = await readBodyMutationLogs(page, 1) - return !records.some((record) => !!record) -} - -export async function nextAttributeMutationNamed( - page: Page, - elementId: string, - attributeName: string -): Promise { - let record: MutationLog | undefined - while (!record) { - const records = await readMutationLogs(page, 1) - record = records.find(([name, id]) => name == attributeName && id == elementId) - } - const attributeValue = record[2] - return attributeValue -} - -export async function noNextAttributeMutationNamed( - page: Page, - elementId: string, - attributeName: string -): Promise { - const records = await readMutationLogs(page, 1) - return !records.some(([name, _, target]) => name == attributeName && target == elementId) -} - -export async function noNextEventNamed(page: Page, eventName: string): Promise { - const records = await readEventLogs(page, 1) - return !records.some(([name]) => name == eventName) -} - -export async function noNextEventOnTarget(page: Page, elementId: string, eventName: string): Promise { - const records = await readEventLogs(page, 1) - return !records.some(([name, _, target]) => name == eventName && target == elementId) -} - -export async function outerHTMLForSelector(page: Page, selector: string): Promise { - const element = await page.locator(selector) - return element.evaluate((element) => element.outerHTML) -} - -export function pathname(url: string): string { - const { pathname } = new URL(url) - - return pathname -} - -export async function pathnameForIFrame(page: Page, name: string) { - const locator = await page.locator(`[name="${name}"]`) - const location = await locator.evaluate((iframe: HTMLIFrameElement) => iframe.contentWindow?.location) - - if (location) { - return pathname(location.href) - } else { - return "" - } -} - -export function propertyForSelector(page: Page, selector: string, propertyName: string): Promise { - return page.locator(selector).evaluate((element, propertyName) => (element as any)[propertyName], propertyName) -} - -async function readArray(page: Page, identifier: string, length?: number): Promise { - return page.evaluate( - ({ identifier, length }) => { - const records = (window as any)[identifier] - if (records != null && typeof records.splice == "function") { - return records.splice(0, typeof length === "undefined" ? records.length : length) - } else { - return [] - } - }, - { identifier, length } - ) -} - -export function readBodyMutationLogs(page: Page, length?: number): Promise { - return readArray(page, "bodyMutationLogs", length) -} - -export function readEventLogs(page: Page, length?: number): Promise { - return readArray(page, "eventLogs", length) -} - -export function readMutationLogs(page: Page, length?: number): Promise { - return readArray(page, "mutationLogs", length) -} - -export function search(url: string): string { - const { search } = new URL(url) - - return search -} - -export function searchParams(url: string): URLSearchParams { - const { searchParams } = new URL(url) - - return searchParams -} - -export function selectorHasFocus(page: Page, selector: string): Promise { - return page.locator(selector).evaluate((element) => element === document.activeElement) -} - -export function setLocalStorageFromEvent(page: Page, eventName: string, storageKey: string, storageValue: string) { - return page.evaluate( - ({ eventName, storageKey, storageValue }) => { - addEventListener(eventName, () => localStorage.setItem(storageKey, storageValue)) - }, - { eventName, storageKey, storageValue } - ) -} - -export function scrollPosition(page: Page): Promise<{ x: number; y: number }> { - return page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })) -} - -export async function isScrolledToTop(page: Page): Promise { - const { y: pageY } = await scrollPosition(page) - return pageY === 0 -} - -export function scrollToSelector(page: Page, selector: string): Promise { - return page.locator(selector).scrollIntoViewIfNeeded() -} - -export function sleep(timeout = 0): Promise { - return new Promise((resolve) => setTimeout(() => resolve(undefined), timeout)) -} - -export async function strictElementEquals(left: Locator, right: Locator): Promise { - return left.evaluate((left, right) => left === right, await right.elementHandle()) -} - -export function textContent(page: Page, html: string): Promise { - return page.evaluate((html) => { - const parser = new DOMParser() - const { documentElement } = parser.parseFromString(html, "text/html") - - return documentElement.textContent - }, html) -} - -export function visitAction(page: Page): Promise { - return page.evaluate(() => { - try { - return window.Turbo.navigator.currentVisit!.action - } catch (error) { - return "load" - } - }) -} - -export function waitForPathname(page: Page, pathname: string): Promise { - return page.waitForURL((url) => url.pathname == pathname) -} - -export function waitUntilText(page: Page, text: string, state: "visible" | "attached" = "visible") { - return page.waitForSelector(`text='${text}'`, { state }) -} - -export function waitUntilSelector(page: Page, selector: string, state: "visible" | "attached" = "visible") { - return page.waitForSelector(selector, { state }) -} - -export function waitUntilNoSelector(page: Page, selector: string, state: "hidden" | "detached" = "hidden") { - return page.waitForSelector(selector, { state }) -} - -export async function willChangeBody(page: Page, callback: () => Promise): Promise { - const handles: JSHandle[] = [] - - try { - const originalBody = await page.evaluateHandle(() => document.body) - handles.push(originalBody) - - await callback() - - const latestBody = await page.evaluateHandle(() => document.body) - handles.push(latestBody) - - return page.evaluate(({ originalBody, latestBody }) => originalBody !== latestBody, { originalBody, latestBody }) - } finally { - disposeAll(...handles) - } -} diff --git a/src/tests/integration/ujs_tests.ts b/src/tests/integration/ujs_tests.js similarity index 85% rename from src/tests/integration/ujs_tests.ts rename to src/tests/integration/ujs_tests.js index e90736e21..4bc50d172 100644 --- a/src/tests/integration/ujs_tests.ts +++ b/src/tests/integration/ujs_tests.js @@ -1,4 +1,4 @@ -import { Page, test } from "@playwright/test" +import { test } from "@playwright/test" import { assert } from "chai" import { nextEventOnTarget, noNextEventOnTarget } from "../helpers/page" @@ -28,7 +28,12 @@ test("handles [data-remote=true] forms within a turbo-frame", async ({ page }) = }) }) -async function assertRequestLimit(page: Page, count: number, callback: () => Promise) { +/** @param {Page} page + * @param {number} count + * @param {() => Promise} callback + * @returns {Promise} + */ +async function assertRequestLimit(page, count, callback) { let requestsStarted = 0 await page.on("request", () => requestsStarted++) await callback() diff --git a/src/tests/server.ts b/src/tests/server.js similarity index 86% rename from src/tests/server.ts rename to src/tests/server.js index 399e930be..29e1da747 100644 --- a/src/tests/server.ts +++ b/src/tests/server.js @@ -1,4 +1,4 @@ -import { Request, Response, Router } from "express" +import { Router } from "express" import express from "express" import { json, urlencoded } from "body-parser" import multer from "multer" @@ -7,7 +7,7 @@ import url from "url" import fs from "fs" const router = Router() -const streamResponses: Set = new Set() +const streamResponses = new Set() router.use(multer().none()) @@ -36,7 +36,7 @@ router.post("/redirect", (request, response) => { }) router.get("/redirect", (request, response) => { - const { path, ...query } = request.query as any + const { path, ...query } = request.query const pathname = path ?? "/src/tests/fixtures/one.html" const enctype = request.get("Content-Type") if (enctype) { @@ -136,7 +136,12 @@ router.get("/messages", (request, response) => { streamResponses.add(response) }) -function receiveMessage(content: string, id: string | null, target?: string) { +/** @param {string} content + * @param {string | null} id + * @param {string} [target] + * @returns {void} + */ +function receiveMessage(content, id, target) { const data = renderSSEData(renderMessage(content, id, target)) for (const response of streamResponses) { console.log("delivering message to stream", response.socket?.remotePort) @@ -144,7 +149,11 @@ function receiveMessage(content: string, id: string | null, target?: string) { } } -function renderMessage(content: string, id: string | null, target = "messages") { +/** @param {string} content + * @param {string | null} id + * @returns {string} + */ +function renderMessage(content, id, target = "messages") { return `