From 875ad8c68afe158412219a72780c89026c17a7e2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 6 Sep 2023 13:05:10 -0700 Subject: [PATCH] Add TypeScript This reverts commit 9f3aad7772ba8ef4080538e4e5fb175a8ad550f1. --- .eslintrc | 23 ++ .eslintrc.js | 42 --- package.json | 18 +- playwright.config.js | 29 -- playwright.config.ts | 28 ++ rollup.config.js | 8 +- src/core/{bardo.js => bardo.ts} | 32 +- src/core/{cache.js => cache.ts} | 13 +- .../{error_renderer.js => error_renderer.ts} | 7 +- ...{form_submission.js => form_submission.ts} | 110 +++--- .../{head_snapshot.js => head_snapshot.ts} | 64 ++-- src/core/drive/{history.js => history.ts} | 37 ++- src/core/drive/{navigator.js => navigator.ts} | 48 ++- .../{page_renderer.js => page_renderer.ts} | 16 +- .../{page_snapshot.js => page_snapshot.ts} | 16 +- src/core/drive/{page_view.js => page_view.ts} | 25 +- src/core/drive/{preloader.js => preloader.ts} | 19 +- .../{progress_bar.js => progress_bar.ts} | 8 +- .../{snapshot_cache.js => snapshot_cache.ts} | 20 +- src/core/drive/view_transitioner.js | 21 -- src/core/drive/view_transitioner.ts | 31 ++ src/core/drive/{visit.js => visit.ts} | 145 +++++--- src/core/{errors.js => errors.ts} | 0 ...rame_controller.js => frame_controller.ts} | 291 ++++++++-------- src/core/frames/frame_redirector.js | 85 ----- src/core/frames/frame_redirector.ts | 86 +++++ .../{frame_renderer.js => frame_renderer.ts} | 27 +- src/core/frames/frame_view.js | 12 - src/core/frames/frame_view.ts | 15 + ...ink_interceptor.js => link_interceptor.ts} | 25 +- src/core/{index.js => index.ts} | 41 ++- src/core/native/adapter.ts | 18 + ...{browser_adapter.js => browser_adapter.ts} | 54 +-- src/core/renderer.js | 86 ----- src/core/renderer.ts | 100 ++++++ src/core/{session.js => session.ts} | 213 +++++++----- src/core/{snapshot.js => snapshot.ts} | 22 +- .../{stream_actions.js => stream_actions.ts} | 9 +- .../{stream_message.js => stream_message.ts} | 12 +- ...renderer.js => stream_message_renderer.ts} | 20 +- src/core/types.ts | 16 + src/core/{url.js => url.ts} | 30 +- src/core/{view.js => view.ts} | 66 ++-- .../{frame_element.js => frame_element.ts} | 51 ++- src/elements/{index.js => index.ts} | 0 .../{stream_element.js => stream_element.ts} | 32 +- ...ce_element.js => stream_source_element.ts} | 5 +- src/globals.d.ts | 13 + src/http/fetch_request.js | 146 -------- src/http/fetch_request.ts | 194 +++++++++++ .../{fetch_response.js => fetch_response.ts} | 12 +- src/http/index.js | 2 - src/http/index.ts | 5 + src/{index.js => index.ts} | 0 ...nce_observer.js => appearance_observer.ts} | 13 +- .../{cache_observer.js => cache_observer.ts} | 10 +- ...bserver.js => form_link_click_observer.ts} | 20 +- ...it_observer.js => form_submit_observer.ts} | 17 +- ...ick_observer.js => link_click_observer.ts} | 23 +- .../{page_observer.js => page_observer.ts} | 19 +- ...{scroll_observer.js => scroll_observer.ts} | 11 +- ...{stream_observer.js => stream_observer.ts} | 43 ++- src/polyfills/custom-elements-native-shim.ts | 50 +++ src/polyfills/form-request-submit-polyfill.js | 17 +- src/polyfills/{index.js => index.ts} | 1 + .../{submit-event.js => submit-event.ts} | 16 +- src/{script_warning.js => script_warning.ts} | 2 +- src/tests/fixtures/test.js | 105 +++--- ..._script_tests.js => async_script_tests.ts} | 0 ...{autofocus_tests.js => autofocus_tests.ts} | 4 +- ...erver_tests.js => cache_observer_tests.ts} | 0 ...abled_tests.js => drive_disabled_tests.ts} | 2 +- .../{drive_tests.js => drive_tests.ts} | 0 ...ests.js => drive_view_transition_tests.ts} | 0 ...{form_mode_tests.js => form_mode_tests.ts} | 6 +- ...sion_tests.js => form_submission_tests.ts} | 24 +- ...ion_tests.js => frame_navigation_tests.ts} | 0 .../{frame_tests.js => frame_tests.ts} | 109 +++--- .../{import_tests.js => import_tests.ts} | 0 .../{loading_tests.js => loading_tests.ts} | 10 +- ...avigation_tests.js => navigation_tests.ts} | 12 +- ...g_tests.js => pausable_rendering_tests.ts} | 0 ...ts_tests.js => pausable_requests_tests.ts} | 0 ...{preloader_tests.js => preloader_tests.ts} | 0 ...{rendering_tests.js => rendering_tests.ts} | 65 ++-- ...n_tests.js => scroll_restoration_tests.ts} | 0 .../{stream_tests.js => stream_tests.ts} | 12 +- .../{visit_tests.js => visit_tests.ts} | 22 +- .../{dom_test_case.js => dom_test_case.ts} | 6 +- src/tests/helpers/page.js | 288 ---------------- src/tests/helpers/page.ts | 313 ++++++++++++++++++ .../{ujs_tests.js => ujs_tests.ts} | 4 +- src/tests/{server.mjs => server.ts} | 26 +- .../unit/deprecated_adapter_support_tests.js | 58 ---- .../unit/deprecated_adapter_support_tests.ts | 61 ++++ .../unit/{export_tests.js => export_tests.ts} | 30 ++ ...ement_tests.js => stream_element_tests.ts} | 6 +- src/{util.js => util.ts} | 60 ++-- tsconfig.json | 17 + web-test-runner.config.mjs | 2 +- yarn.lock | 160 +++++++++ 101 files changed, 2472 insertions(+), 1620 deletions(-) create mode 100644 .eslintrc delete mode 100644 .eslintrc.js delete mode 100644 playwright.config.js create mode 100644 playwright.config.ts rename src/core/{bardo.js => bardo.ts} (59%) rename src/core/{cache.js => cache.ts} (54%) rename src/core/drive/{error_renderer.js => error_renderer.ts} (81%) rename src/core/drive/{form_submission.js => form_submission.ts} (67%) rename src/core/drive/{head_snapshot.js => head_snapshot.ts} (59%) rename src/core/drive/{history.js => history.ts} (68%) rename src/core/drive/{navigator.js => navigator.ts} (67%) rename src/core/drive/{page_renderer.js => page_renderer.ts} (88%) rename src/core/drive/{page_snapshot.js => page_snapshot.ts} (80%) rename src/core/drive/{page_view.js => page_view.ts} (62%) rename src/core/drive/{preloader.js => preloader.ts} (64%) rename src/core/drive/{progress_bar.js => progress_bar.ts} (93%) rename src/core/drive/{snapshot_cache.js => snapshot_cache.ts} (67%) delete mode 100644 src/core/drive/view_transitioner.js create mode 100644 src/core/drive/view_transitioner.ts rename src/core/drive/{visit.js => visit.ts} (74%) rename src/core/{errors.js => errors.ts} (100%) rename src/core/frames/{frame_controller.js => frame_controller.ts} (54%) delete mode 100644 src/core/frames/frame_redirector.js create mode 100644 src/core/frames/frame_redirector.ts rename src/core/frames/{frame_renderer.js => frame_renderer.ts} (70%) delete mode 100644 src/core/frames/frame_view.js create mode 100644 src/core/frames/frame_view.ts rename src/core/frames/{link_interceptor.js => link_interceptor.ts} (65%) rename src/core/{index.js => index.ts} (67%) create mode 100644 src/core/native/adapter.ts rename src/core/native/{browser_adapter.js => browser_adapter.ts} (64%) delete mode 100644 src/core/renderer.js create mode 100644 src/core/renderer.ts rename src/core/{session.js => session.ts} (54%) rename src/core/{snapshot.js => snapshot.ts} (70%) rename src/core/streams/{stream_actions.js => stream_actions.ts} (76%) rename src/core/streams/{stream_message.js => stream_message.ts} (59%) rename src/core/streams/{stream_message_renderer.js => stream_message_renderer.ts} (51%) create mode 100644 src/core/types.ts rename src/core/{url.js => url.ts} (61%) rename src/core/{view.js => view.ts} (51%) rename src/elements/{frame_element.js => frame_element.ts} (67%) rename src/elements/{index.js => index.ts} (100%) rename src/elements/{stream_element.js => stream_element.ts} (84%) rename src/elements/{stream_source_element.js => stream_source_element.ts} (81%) create mode 100644 src/globals.d.ts delete mode 100644 src/http/fetch_request.js create mode 100644 src/http/fetch_request.ts rename src/http/{fetch_response.js => fetch_response.ts} (82%) delete mode 100644 src/http/index.js create mode 100644 src/http/index.ts rename src/{index.js => index.ts} (100%) rename src/observers/{appearance_observer.js => appearance_observer.ts} (57%) rename src/observers/{cache_observer.js => cache_observer.ts} (77%) rename src/observers/{form_link_click_observer.js => form_link_click_observer.ts} (70%) rename src/observers/{form_submit_observer.js => form_submit_observer.ts} (71%) rename src/observers/{link_click_observer.js => link_click_observer.ts} (67%) rename src/observers/{page_observer.js => page_observer.ts} (82%) rename src/observers/{scroll_observer.js => scroll_observer.ts} (66%) rename src/observers/{stream_observer.js => stream_observer.ts} (57%) create mode 100644 src/polyfills/custom-elements-native-shim.ts rename src/polyfills/{index.js => index.ts} (62%) rename src/polyfills/{submit-event.js => submit-event.ts} (73%) rename src/{script_warning.js => script_warning.ts} (93%) rename src/tests/functional/{async_script_tests.js => async_script_tests.ts} (100%) rename src/tests/functional/{autofocus_tests.js => autofocus_tests.ts} (99%) rename src/tests/functional/{cache_observer_tests.js => cache_observer_tests.ts} (100%) rename src/tests/functional/{drive_disabled_tests.js => drive_disabled_tests.ts} (99%) rename src/tests/functional/{drive_tests.js => drive_tests.ts} (100%) rename src/tests/functional/{drive_view_transition_tests.js => drive_view_transition_tests.ts} (100%) rename src/tests/functional/{form_mode_tests.js => form_mode_tests.ts} (93%) rename src/tests/functional/{form_submission_tests.js => form_submission_tests.ts} (99%) rename src/tests/functional/{frame_navigation_tests.js => frame_navigation_tests.ts} (100%) rename src/tests/functional/{frame_tests.js => frame_tests.ts} (95%) rename src/tests/functional/{import_tests.js => import_tests.ts} (100%) rename src/tests/functional/{loading_tests.js => loading_tests.ts} (97%) rename src/tests/functional/{navigation_tests.js => navigation_tests.ts} (99%) rename src/tests/functional/{pausable_rendering_tests.js => pausable_rendering_tests.ts} (100%) rename src/tests/functional/{pausable_requests_tests.js => pausable_requests_tests.ts} (100%) rename src/tests/functional/{preloader_tests.js => preloader_tests.ts} (100%) rename src/tests/functional/{rendering_tests.js => rendering_tests.ts} (92%) rename src/tests/functional/{scroll_restoration_tests.js => scroll_restoration_tests.ts} (100%) rename src/tests/functional/{stream_tests.js => stream_tests.ts} (92%) rename src/tests/functional/{visit_tests.js => visit_tests.ts} (92%) rename src/tests/helpers/{dom_test_case.js => dom_test_case.ts} (86%) delete mode 100644 src/tests/helpers/page.js create mode 100644 src/tests/helpers/page.ts rename src/tests/integration/{ujs_tests.js => ujs_tests.ts} (90%) rename src/tests/{server.mjs => server.ts} (89%) delete mode 100644 src/tests/unit/deprecated_adapter_support_tests.js create mode 100644 src/tests/unit/deprecated_adapter_support_tests.ts rename src/tests/unit/{export_tests.js => export_tests.ts} (54%) rename src/tests/unit/{stream_element_tests.js => stream_element_tests.ts} (96%) rename src/{util.js => util.ts} (64%) create mode 100644 tsconfig.json diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..ff5909ef3 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,23 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint", + "prettier" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }], + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "no-unused-vars": "off", + "prettier/prettier": ["error"] + } +} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index ba8803d0b..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,42 +0,0 @@ -module.exports = { - env: { - browser: true, - es2021: true - }, - extends: ["eslint:recommended"], - overrides: [ - { - env: { - node: true - }, - files: [".eslintrc.{js,cjs}"], - parserOptions: { - sourceType: "script" - } - } - ], - parserOptions: { - ecmaVersion: "latest", - sourceType: "module" - }, - rules: { - "comma-dangle": "error", - "curly": ["error", "multi-line"], - "getter-return": "off", - "no-console": "off", - "no-duplicate-imports": ["error"], - "no-multi-spaces": ["error", { "exceptions": { "VariableDeclarator": true }}], - "no-multiple-empty-lines": ["error", { "max": 2 }], - "no-self-assign": ["error", { "props": false }], - "no-trailing-spaces": ["error"], - "no-unused-vars": ["error", { argsIgnorePattern: "_*" }], - "no-useless-escape": "off", - "no-var": ["error"], - "prefer-const": ["error"], - "semi": ["error", "never"] - }, - globals: { - test: true, - setup: true - } -} diff --git a/package.json b/package.json index f7a9c0bac..8ed7e21a3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "@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", "@web/dev-server-esbuild": "^0.3.3", "@web/test-runner": "^0.15.0", "@web/test-runner-playwright": "^0.9.0", @@ -47,23 +49,29 @@ "body-parser": "^1.20.1", "chai": "~4.3.4", "eslint": "^8.13.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-prettier": "^4.0.0", "express": "^4.18.2", "multer": "^1.4.2", - "rollup": "^2.35.1" + "prettier": "2.6.2", + "rollup": "^2.35.1", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "typescript": "^4.9.5" }, "scripts": { "clean": "rm -fr dist", "clean:win": "rmdir /s /q dist", - "build": "rollup -c", - "build:win": "rollup -c", + "build": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types && rollup -c", + "build:win": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types & rollup -c", "watch": "rollup -wc", - "start": "node src/tests/server.mjs", + "start": "ts-node -O '{\"module\":\"commonjs\"}' src/tests/server.ts", "test": "yarn test:unit && yarn test:browser", "test:browser": "playwright test", "test:unit": "NODE_OPTIONS=--inspect web-test-runner", "test:unit:win": "SET NODE_OPTIONS=--inspect & web-test-runner", "release": "yarn build && npm publish", - "lint": "eslint . --ext .js" + "lint": "eslint . --ext .ts" }, "engines": { "node": ">= 14" diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index a4ee43023..000000000 --- a/playwright.config.js +++ /dev/null @@ -1,29 +0,0 @@ -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\.js/, - webServer: { - command: "yarn start", - url: "http://localhost:9000/src/tests/fixtures/test.js", - timeout: 120 * 1000, - // eslint-disable-next-line no-undef - reuseExistingServer: !process.env.CI - }, - use: { - baseURL: "http://localhost:9000/" - } -} - -export default config diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..bef521f7c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { type PlaywrightTestConfig, devices } from "@playwright/test" + +const config: PlaywrightTestConfig = { + 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 395c4d497..5073079dd 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,5 @@ import resolve from "@rollup/plugin-node-resolve" +import typescript from "@rollup/plugin-typescript" import { version } from "./package.json" const year = new Date().getFullYear() @@ -6,7 +7,7 @@ const banner = `/*!\nTurbo ${version}\nCopyright © ${year} 37signals LLC\n */` export default [ { - input: "src/index.js", + input: "src/index.ts", output: [ { name: "Turbo", @@ -20,7 +21,10 @@ export default [ banner } ], - plugins: [resolve()], + plugins: [ + resolve(), + typescript() + ], watch: { include: "src/**" } diff --git a/src/core/bardo.js b/src/core/bardo.ts similarity index 59% rename from src/core/bardo.js rename to src/core/bardo.ts index 01355fb03..bd6b191f0 100644 --- a/src/core/bardo.js +++ b/src/core/bardo.ts @@ -1,12 +1,26 @@ +import { PermanentElementMap } from "./snapshot" + +export interface BardoDelegate { + enteringBardo(currentPermanentElement: Element, newPermanentElement: Element): void + leavingBardo(currentPermanentElement: Element): void +} + export class Bardo { - static async preservingPermanentElements(delegate, permanentElementMap, callback) { + readonly permanentElementMap: PermanentElementMap + readonly delegate: BardoDelegate + + static async preservingPermanentElements( + delegate: BardoDelegate, + permanentElementMap: PermanentElementMap, + callback: () => void + ) { const bardo = new this(delegate, permanentElementMap) bardo.enter() await callback() bardo.leave() } - constructor(delegate, permanentElementMap) { + constructor(delegate: BardoDelegate, permanentElementMap: PermanentElementMap) { this.delegate = delegate this.permanentElementMap = permanentElementMap } @@ -28,31 +42,31 @@ export class Bardo { } } - replaceNewPermanentElementWithPlaceholder(permanentElement) { + replaceNewPermanentElementWithPlaceholder(permanentElement: Element) { const placeholder = createPlaceholderForPermanentElement(permanentElement) permanentElement.replaceWith(placeholder) } - replaceCurrentPermanentElementWithClone(permanentElement) { + replaceCurrentPermanentElementWithClone(permanentElement: Element) { const clone = permanentElement.cloneNode(true) permanentElement.replaceWith(clone) } - replacePlaceholderWithPermanentElement(permanentElement) { + replacePlaceholderWithPermanentElement(permanentElement: Element) { const placeholder = this.getPlaceholderById(permanentElement.id) placeholder?.replaceWith(permanentElement) } - getPlaceholderById(id) { + getPlaceholderById(id: string) { return this.placeholders.find((element) => element.content == id) } - get placeholders() { - return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] + get placeholders(): HTMLMetaElement[] { + return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] } } -function createPlaceholderForPermanentElement(permanentElement) { +function createPlaceholderForPermanentElement(permanentElement: Element) { const element = document.createElement("meta") element.setAttribute("name", "turbo-permanent-placeholder") element.setAttribute("content", permanentElement.id) diff --git a/src/core/cache.js b/src/core/cache.ts similarity index 54% rename from src/core/cache.js rename to src/core/cache.ts index 2c163e6f4..715b7d098 100644 --- a/src/core/cache.js +++ b/src/core/cache.ts @@ -1,7 +1,10 @@ +import { Session } from "./session" import { setMetaContent } from "../util" export class Cache { - constructor(session) { + readonly session: Session + + constructor(session: Session) { this.session = session } @@ -10,18 +13,18 @@ export class Cache { } resetCacheControl() { - this.#setCacheControl("") + this.setCacheControl("") } exemptPageFromCache() { - this.#setCacheControl("no-cache") + this.setCacheControl("no-cache") } exemptPageFromPreview() { - this.#setCacheControl("no-preview") + this.setCacheControl("no-preview") } - #setCacheControl(value) { + private setCacheControl(value: string) { setMetaContent("turbo-cache-control", value) } } diff --git a/src/core/drive/error_renderer.js b/src/core/drive/error_renderer.ts similarity index 81% rename from src/core/drive/error_renderer.js rename to src/core/drive/error_renderer.ts index 0e5f2ce39..22deb43e7 100644 --- a/src/core/drive/error_renderer.js +++ b/src/core/drive/error_renderer.ts @@ -1,8 +1,9 @@ -import { activateScriptElement } from "../../util" +import { PageSnapshot } from "./page_snapshot" import { Renderer } from "../renderer" +import { activateScriptElement } from "../../util" -export class ErrorRenderer extends Renderer { - static renderElement(currentElement, newElement) { +export class ErrorRenderer extends Renderer { + static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { const { documentElement, body } = document documentElement.replaceChild(newElement, body) diff --git a/src/core/drive/form_submission.js b/src/core/drive/form_submission.ts similarity index 67% rename from src/core/drive/form_submission.js rename to src/core/drive/form_submission.ts index 9b72131d4..efb75bc01 100644 --- a/src/core/drive/form_submission.js +++ b/src/core/drive/form_submission.ts @@ -1,24 +1,40 @@ 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 const FormSubmissionState = { - initialized: "initialized", - requesting: "requesting", - waiting: "waiting", - receiving: "receiving", - stopping: "stopping", - stopped: "stopped" +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 const FormEnctype = { - urlEncoded: "application/x-www-form-urlencoded", - multipart: "multipart/form-data", - plain: "text/plain" +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", } -function formEnctypeFromString(encoding) { +export type TurboSubmitStartEvent = CustomEvent<{ formSubmission: FormSubmission }> +export type TurboSubmitEndEvent = CustomEvent< + { formSubmission: FormSubmission } & { [K in keyof FormSubmissionResult]?: FormSubmissionResult[K] } +> + +function formEnctypeFromString(encoding: string): FormEnctype { switch (encoding.toLowerCase()) { case FormEnctype.multipart: return FormEnctype.multipart @@ -30,13 +46,31 @@ function formEnctypeFromString(encoding) { } export class FormSubmission { + readonly delegate: FormSubmissionDelegate + readonly formElement: HTMLFormElement + readonly submitter?: HTMLElement + readonly formData: FormData + readonly location: URL + readonly fetchRequest: FetchRequest + readonly mustRedirect: boolean state = FormSubmissionState.initialized - - static confirmMethod(message, _element, _submitter) { + result?: FormSubmissionResult + originalSubmitText?: string + + static confirmMethod( + message: string, + _element: HTMLFormElement, + _submitter: HTMLElement | undefined + ): Promise { return Promise.resolve(confirm(message)) } - constructor(delegate, formElement, submitter, mustRedirect = false) { + constructor( + delegate: FormSubmissionDelegate, + formElement: HTMLFormElement, + submitter?: HTMLElement, + mustRedirect = false + ) { this.delegate = delegate this.formElement = formElement this.submitter = submitter @@ -49,12 +83,12 @@ export class FormSubmission { this.mustRedirect = mustRedirect } - get method() { + get method(): FetchMethod { const method = this.submitter?.getAttribute("formmethod") || this.formElement.getAttribute("method") || "" return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get } - get action() { + get action(): string { const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null if (this.submitter?.hasAttribute("formaction")) { @@ -72,7 +106,7 @@ export class FormSubmission { } } - get enctype() { + get enctype(): FormEnctype { return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.formElement.enctype) } @@ -83,7 +117,7 @@ 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 @@ -116,7 +150,7 @@ export class FormSubmission { // Fetch request delegate - prepareRequest(request) { + prepareRequest(request: FetchRequest) { if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token") if (token) { @@ -129,22 +163,22 @@ export class FormSubmission { } } - requestStarted(_request) { + requestStarted(_request: FetchRequest) { this.state = FormSubmissionState.waiting this.submitter?.setAttribute("disabled", "") this.setSubmitsWith() - dispatch("turbo:submit-start", { + dispatch("turbo:submit-start", { target: this.formElement, - detail: { formSubmission: this } + detail: { formSubmission: this }, }) this.delegate.formSubmissionStarted(this) } - requestPreventedHandlingResponse(request, response) { + requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { this.result = { success: response.succeeded, fetchResponse: response } } - requestSucceededWithResponse(request, response) { + requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response) } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { @@ -157,23 +191,23 @@ export class FormSubmission { } } - requestFailedWithResponse(request, response) { + requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { this.result = { success: false, fetchResponse: response } this.delegate.formSubmissionFailedWithResponse(this, response) } - requestErrored(request, error) { + requestErrored(request: FetchRequest, error: Error) { this.result = { success: false, error } this.delegate.formSubmissionErrored(this, error) } - requestFinished(_request) { + requestFinished(_request: FetchRequest) { 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 } + detail: { formSubmission: this, ...this.result }, }) this.delegate.formSubmissionFinished(this) } @@ -187,7 +221,7 @@ export class FormSubmission { this.originalSubmitText = this.submitter.innerHTML this.submitter.innerHTML = this.submitsWith } else if (this.submitter.matches("input")) { - const input = this.submitter + const input = this.submitter as HTMLInputElement this.originalSubmitText = input.value input.value = this.submitsWith } @@ -199,16 +233,16 @@ export class FormSubmission { if (this.submitter.matches("button")) { this.submitter.innerHTML = this.originalSubmitText } else if (this.submitter.matches("input")) { - const input = this.submitter + const input = this.submitter as HTMLInputElement input.value = this.originalSubmitText } } - requestMustRedirect(request) { + requestMustRedirect(request: FetchRequest) { return !request.isSafe && this.mustRedirect } - requestAcceptsTurboStreamResponse(request) { + requestAcceptsTurboStreamResponse(request: FetchRequest) { return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) } @@ -217,7 +251,7 @@ export class FormSubmission { } } -function buildFormData(formElement, submitter) { +function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData { const formData = new FormData(formElement) const name = submitter?.getAttribute("name") const value = submitter?.getAttribute("value") @@ -229,7 +263,7 @@ function buildFormData(formElement, submitter) { return formData } -function getCookieValue(cookieName) { +function getCookieValue(cookieName: string | null) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split("; ") : [] const cookie = cookies.find((cookie) => cookie.startsWith(cookieName)) @@ -240,11 +274,11 @@ function getCookieValue(cookieName) { } } -function responseSucceededWithoutRedirect(response) { +function responseSucceededWithoutRedirect(response: FetchResponse) { return response.statusCode == 200 && !response.redirected } -function mergeFormDataEntries(url, entries) { +function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL { const searchParams = new URLSearchParams() for (const [name, value] of entries) { diff --git a/src/core/drive/head_snapshot.js b/src/core/drive/head_snapshot.ts similarity index 59% rename from src/core/drive/head_snapshot.js rename to src/core/drive/head_snapshot.ts index a015bbc28..3892332c1 100644 --- a/src/core/drive/head_snapshot.js +++ b/src/core/drive/head_snapshot.ts @@ -1,51 +1,61 @@ import { Snapshot } from "../snapshot" -export class HeadSnapshot extends Snapshot { - detailsByOuterHTML = this.children +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 = + const details: ElementDetails = outerHTML in result ? result[outerHTML] : { type: elementType(element), tracked: elementIsTracked(element), - elements: [] + elements: [], } return { ...result, [outerHTML]: { ...details, - elements: [...details.elements, element] - } + elements: [...details.elements, element], + }, } - }, {}) + }, {} as ElementDetailMap) - get trackedElementSignature() { + get trackedElementSignature(): string { return Object.keys(this.detailsByOuterHTML) .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) .join("") } - getScriptElementsNotInSnapshot(snapshot) { - return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) + getScriptElementsNotInSnapshot(snapshot: HeadSnapshot) { + return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) } - getStylesheetElementsNotInSnapshot(snapshot) { - return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) + getStylesheetElementsNotInSnapshot(snapshot: HeadSnapshot) { + return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) } - getElementsMatchingTypeNotInSnapshot(matchedType, 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) + .map(({ elements: [element] }) => element) as T[] } - get provisionalElements() { + get provisionalElements(): Element[] { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML] if (type == null && !tracked) { @@ -55,25 +65,25 @@ export class HeadSnapshot extends Snapshot { } else { return result } - }, []) + }, [] as Element[]) } - getMetaValue(name) { + getMetaValue(name: string): string | null { const element = this.findMetaElementByName(name) return element ? element.getAttribute("content") : null } - findMetaElementByName(name) { + findMetaElementByName(name: string) { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { - elements: [element] + elements: [element], } = this.detailsByOuterHTML[outerHTML] return elementIsMetaElementWithName(element, name) ? element : result - }, undefined | undefined) + }, undefined as Element | undefined) } } -function elementType(element) { +function elementType(element: Element) { if (elementIsScript(element)) { return "script" } else if (elementIsStylesheet(element)) { @@ -81,31 +91,31 @@ function elementType(element) { } } -function elementIsTracked(element) { +function elementIsTracked(element: Element) { return element.getAttribute("data-turbo-track") == "reload" } -function elementIsScript(element) { +function elementIsScript(element: Element) { const tagName = element.localName return tagName == "script" } -function elementIsNoscript(element) { +function elementIsNoscript(element: Element) { const tagName = element.localName return tagName == "noscript" } -function elementIsStylesheet(element) { +function elementIsStylesheet(element: Element) { const tagName = element.localName return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet") } -function elementIsMetaElementWithName(element, name) { +function elementIsMetaElementWithName(element: Element, name: string) { const tagName = element.localName return tagName == "meta" && element.getAttribute("name") == name } -function elementWithoutNonce(element) { +function elementWithoutNonce(element: Element) { if (element.hasAttribute("nonce")) { element.setAttribute("nonce", "") } diff --git a/src/core/drive/history.js b/src/core/drive/history.ts similarity index 68% rename from src/core/drive/history.js rename to src/core/drive/history.ts index 215f56eca..7143adb1b 100644 --- a/src/core/drive/history.js +++ b/src/core/drive/history.ts @@ -1,13 +1,28 @@ +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 { - location + readonly delegate: HistoryDelegate + location!: URL restorationIdentifier = uuid() - restorationData = {} + restorationData: RestorationDataMap = {} started = false pageLoaded = false + previousScrollRestoration?: ScrollRestoration - constructor(delegate) { + constructor(delegate: HistoryDelegate) { this.delegate = delegate } @@ -28,15 +43,15 @@ export class History { } } - push(location, restorationIdentifier) { + push(location: URL, restorationIdentifier?: string) { this.update(history.pushState, location, restorationIdentifier) } - replace(location, restorationIdentifier) { + replace(location: URL, restorationIdentifier?: string) { this.update(history.replaceState, location, restorationIdentifier) } - update(method, location, restorationIdentifier = uuid()) { + update(method: HistoryMethod, location: URL, restorationIdentifier = uuid()) { const state = { turbo: { restorationIdentifier } } method.call(history, state, "", location.href) this.location = location @@ -45,16 +60,16 @@ export class History { // Restoration data - getRestorationDataForIdentifier(restorationIdentifier) { + getRestorationDataForIdentifier(restorationIdentifier: string): RestorationData { return this.restorationData[restorationIdentifier] || {} } - updateRestorationData(additionalData) { + updateRestorationData(additionalData: Partial) { const { restorationIdentifier } = this const restorationData = this.restorationData[restorationIdentifier] this.restorationData[restorationIdentifier] = { ...restorationData, - ...additionalData + ...additionalData, } } @@ -76,7 +91,7 @@ export class History { // Event handlers - onPopState = (event) => { + onPopState = (event: PopStateEvent) => { if (this.shouldHandlePopState()) { const { turbo } = event.state || {} if (turbo) { @@ -88,7 +103,7 @@ export class History { } } - onPageLoad = async (_event) => { + onPageLoad = async (_event: Event) => { await nextMicrotask() this.pageLoaded = true } diff --git a/src/core/drive/navigator.js b/src/core/drive/navigator.ts similarity index 67% rename from src/core/drive/navigator.js rename to src/core/drive/navigator.ts index be81794fd..7579913f1 100644 --- a/src/core/drive/navigator.js +++ b/src/core/drive/navigator.ts @@ -1,15 +1,27 @@ +import { Action } from "../types" import { getVisitAction } from "../../util" +import { FetchResponse } from "../../http/fetch_response" import { FormSubmission } from "./form_submission" -import { expandURL, getAnchor, getRequestURL, locationIsVisitable } from "../url" -import { Visit } from "./visit" +import { expandURL, getAnchor, getRequestURL, Locatable, locationIsVisitable } from "../url" +import { Visit, VisitDelegate, VisitOptions } 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 { - constructor(delegate) { + readonly delegate: NavigatorDelegate + formSubmission?: FormSubmission + currentVisit?: Visit + + constructor(delegate: NavigatorDelegate) { this.delegate = delegate } - proposeVisit(location, options = {}) { + proposeVisit(location: URL, options: Partial = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { if (locationIsVisitable(location, this.view.snapshot.rootLocation)) { this.delegate.visitProposedToLocation(location, options) @@ -19,16 +31,16 @@ export class Navigator { } } - startVisit(locatable, restorationIdentifier, options = {}) { + startVisit(locatable: Locatable, restorationIdentifier: string, options: Partial = {}) { this.stop() this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { referrer: this.location, - ...options + ...options, }) this.currentVisit.start() } - submitForm(form, submitter) { + submitForm(form: HTMLFormElement, submitter?: HTMLElement) { this.stop() this.formSubmission = new FormSubmission(this, form, submitter, true) @@ -61,14 +73,14 @@ export class Navigator { // Form submission delegate - formSubmissionStarted(formSubmission) { + formSubmissionStarted(formSubmission: FormSubmission) { // Not all adapters implement formSubmissionStarted if (typeof this.adapter.formSubmissionStarted === "function") { this.adapter.formSubmissionStarted(formSubmission) } } - async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { + async formSubmissionSucceededWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { if (formSubmission == this.formSubmission) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { @@ -82,14 +94,14 @@ export class Navigator { const visitOptions = { action, shouldCacheSnapshot, - response: { statusCode, responseHTML, redirected } + response: { statusCode, responseHTML, redirected }, } this.proposeVisit(fetchResponse.location, visitOptions) } } } - async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { + async formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { @@ -104,11 +116,11 @@ export class Navigator { } } - formSubmissionErrored(formSubmission, error) { + formSubmissionErrored(formSubmission: FormSubmission, error: Error) { console.error(error) } - formSubmissionFinished(formSubmission) { + formSubmissionFinished(formSubmission: FormSubmission) { // Not all adapters implement formSubmissionFinished if (typeof this.adapter.formSubmissionFinished === "function") { this.adapter.formSubmissionFinished(formSubmission) @@ -117,15 +129,15 @@ export class Navigator { // Visit delegate - visitStarted(visit) { + visitStarted(visit: Visit) { this.delegate.visitStarted(visit) } - visitCompleted(visit) { + visitCompleted(visit: Visit) { this.delegate.visitCompleted(visit) } - locationWithActionIsSamePage(location, action) { + locationWithActionIsSamePage(location: URL, action?: Action): boolean { const anchor = getAnchor(location) const currentAnchor = getAnchor(this.view.lastRenderedLocation) const isRestorationToTop = action === "restore" && typeof anchor === "undefined" @@ -137,7 +149,7 @@ export class Navigator { ) } - visitScrolledToSamePageLocation(oldURL, newURL) { + visitScrolledToSamePageLocation(oldURL: URL, newURL: URL) { this.delegate.visitScrolledToSamePageLocation(oldURL, newURL) } @@ -151,7 +163,7 @@ export class Navigator { return this.history.restorationIdentifier } - getActionForFormSubmission({ submitter, formElement }) { + getActionForFormSubmission({ submitter, formElement }: FormSubmission): Action { return getVisitAction(submitter, formElement) || "advance" } } diff --git a/src/core/drive/page_renderer.js b/src/core/drive/page_renderer.ts similarity index 88% rename from src/core/drive/page_renderer.js rename to src/core/drive/page_renderer.ts index 0c1a222e6..bb401414a 100644 --- a/src/core/drive/page_renderer.js +++ b/src/core/drive/page_renderer.ts @@ -1,8 +1,10 @@ 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, newElement) { +export class PageRenderer extends Renderer { + static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement) } else { @@ -14,16 +16,16 @@ export class PageRenderer extends Renderer { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } - get reloadReason() { + get reloadReason(): ReloadReason { if (!this.newSnapshot.isVisitable) { return { - reason: "turbo_visit_control_is_reload" + reason: "turbo_visit_control_is_reload", } } if (!this.trackedElementsAreIdentical) { return { - reason: "tracked_element_mismatch" + reason: "tracked_element_mismatch", } } } @@ -80,7 +82,7 @@ export class PageRenderer extends Renderer { const loadingElements = [] for (const element of this.newHeadStylesheetElements) { - loadingElements.push(waitForLoad(element)) + loadingElements.push(waitForLoad(element as HTMLLinkElement)) document.head.appendChild(element) } @@ -108,7 +110,7 @@ export class PageRenderer extends Renderer { } } - isCurrentElementInElementList(element, elementList) { + isCurrentElementInElementList(element: Element, elementList: Element[]) { for (const [index, newElement] of elementList.entries()) { // if title element... if (element.tagName == "TITLE") { diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.ts similarity index 80% rename from src/core/drive/page_snapshot.js rename to src/core/drive/page_snapshot.ts index 575cb25eb..e8b7041a1 100644 --- a/src/core/drive/page_snapshot.js +++ b/src/core/drive/page_snapshot.ts @@ -3,20 +3,22 @@ import { Snapshot } from "../snapshot" import { expandURL } from "../url" import { HeadSnapshot } from "./head_snapshot" -export class PageSnapshot extends Snapshot { +export class PageSnapshot extends Snapshot { static fromHTMLString(html = "") { return this.fromDocument(parseHTMLDocument(html)) } - static fromElement(element) { + static fromElement(element: Element) { return this.fromDocument(element.ownerDocument) } - static fromDocument({ head, body }) { - return new this(body, new HeadSnapshot(head)) + static fromDocument({ head, body }: Document) { + return new this(body as HTMLBodyElement, new HeadSnapshot(head)) } - constructor(element, headSnapshot) { + readonly headSnapshot: HeadSnapshot + + constructor(element: HTMLBodyElement, headSnapshot: HeadSnapshot) { super(element) this.headSnapshot = headSnapshot } @@ -33,7 +35,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 = "" } @@ -71,7 +73,7 @@ export class PageSnapshot extends Snapshot { // Private - getSetting(name) { + getSetting(name: string) { return this.headSnapshot.getMetaValue(`turbo-${name}`) } } diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.ts similarity index 62% rename from src/core/drive/page_view.js rename to src/core/drive/page_view.ts index 1b95bb1d1..dba0defbb 100644 --- a/src/core/drive/page_view.js +++ b/src/core/drive/page_view.ts @@ -1,20 +1,29 @@ import { nextEventLoopTick } from "../../util" -import { View } from "../view" +import { View, ViewDelegate, ViewRenderOptions } from "../view" import { ErrorRenderer } from "./error_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" +import { Visit } from "./visit" -export class PageView extends View { - snapshotCache = new SnapshotCache(10) +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) lastRenderedLocation = new URL(location.href) forceReloaded = false - shouldTransitionTo(newSnapshot) { + shouldTransitionTo(newSnapshot: PageSnapshot) { return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions } - renderPage(snapshot, isPreview = false, willRender = true, visit) { + renderPage(snapshot: PageSnapshot, isPreview = false, willRender = true, visit?: Visit) { const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) if (!renderer.shouldRender) { @@ -26,7 +35,7 @@ export class PageView extends View { return this.render(renderer) } - renderError(snapshot, visit) { + renderError(snapshot: PageSnapshot, visit?: Visit) { visit?.changeHistory() const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false) return this.render(renderer) @@ -36,7 +45,7 @@ export class PageView extends View { this.snapshotCache.clear() } - async cacheSnapshot(snapshot = this.snapshot) { + async cacheSnapshot(snapshot: PageSnapshot = this.snapshot) { if (snapshot.isCacheable) { this.delegate.viewWillCacheSnapshot() const { lastRenderedLocation: location } = this @@ -47,7 +56,7 @@ export class PageView extends View { } } - getCachedSnapshotForLocation(location) { + getCachedSnapshotForLocation(location: URL) { return this.snapshotCache.get(location) } diff --git a/src/core/drive/preloader.js b/src/core/drive/preloader.ts similarity index 64% rename from src/core/drive/preloader.js rename to src/core/drive/preloader.ts index 23871a530..6aaf0b43b 100644 --- a/src/core/drive/preloader.js +++ b/src/core/drive/preloader.ts @@ -1,13 +1,20 @@ +import { Navigator } from "./navigator" import { PageSnapshot } from "./page_snapshot" +import { SnapshotCache } from "./snapshot_cache" + +export interface PreloaderDelegate { + readonly navigator: Navigator +} export class Preloader { - selector = "a[data-turbo-preload]" + readonly delegate: PreloaderDelegate + readonly selector: string = "a[data-turbo-preload]" - constructor(delegate) { + constructor(delegate: PreloaderDelegate) { this.delegate = delegate } - get snapshotCache() { + get snapshotCache(): SnapshotCache { return this.delegate.navigator.view.snapshotCache } @@ -21,13 +28,13 @@ export class Preloader { } } - preloadOnLoadLinksForView(element) { - for (const link of element.querySelectorAll(this.selector)) { + preloadOnLoadLinksForView(element: Element) { + for (const link of element.querySelectorAll(this.selector)) { this.preloadURL(link) } } - async preloadURL(link) { + async preloadURL(link: HTMLAnchorElement) { const location = new URL(link.href) if (this.snapshotCache.has(location)) { diff --git a/src/core/drive/progress_bar.js b/src/core/drive/progress_bar.ts similarity index 93% rename from src/core/drive/progress_bar.js rename to src/core/drive/progress_bar.ts index caff5f5da..9a189be92 100644 --- a/src/core/drive/progress_bar.js +++ b/src/core/drive/progress_bar.ts @@ -21,7 +21,11 @@ export class ProgressBar { ` } + readonly stylesheetElement: HTMLStyleElement + readonly progressElement: HTMLDivElement + hiding = false + trickleInterval?: number value = 0 visible = false @@ -52,7 +56,7 @@ export class ProgressBar { } } - setValue(value) { + setValue(value: number) { this.value = value this.refresh() } @@ -70,7 +74,7 @@ export class ProgressBar { this.refresh() } - fadeProgressElement(callback) { + fadeProgressElement(callback: () => void) { this.progressElement.style.opacity = "0" setTimeout(callback, ProgressBar.animationDuration * 1.5) } diff --git a/src/core/drive/snapshot_cache.js b/src/core/drive/snapshot_cache.ts similarity index 67% rename from src/core/drive/snapshot_cache.js rename to src/core/drive/snapshot_cache.ts index 6ed37e8fd..a93a1bde2 100644 --- a/src/core/drive/snapshot_cache.js +++ b/src/core/drive/snapshot_cache.ts @@ -1,18 +1,20 @@ import { toCacheKey } from "../url" +import { PageSnapshot } from "./page_snapshot" export class SnapshotCache { - keys = [] - snapshots = {} + readonly keys: string[] = [] + readonly size: number + snapshots: { [url: string]: PageSnapshot } = {} - constructor(size) { + constructor(size: number) { this.size = size } - has(location) { + has(location: URL) { return toCacheKey(location) in this.snapshots } - get(location) { + get(location: URL): PageSnapshot | undefined { if (this.has(location)) { const snapshot = this.read(location) this.touch(location) @@ -20,7 +22,7 @@ export class SnapshotCache { } } - put(location, snapshot) { + put(location: URL, snapshot: PageSnapshot) { this.write(location, snapshot) this.touch(location) return snapshot @@ -32,15 +34,15 @@ export class SnapshotCache { // Private - read(location) { + read(location: URL) { return this.snapshots[toCacheKey(location)] } - write(location, snapshot) { + write(location: URL, snapshot: PageSnapshot) { this.snapshots[toCacheKey(location)] = snapshot } - touch(location) { + touch(location: URL) { const key = toCacheKey(location) const index = this.keys.indexOf(key) if (index > -1) this.keys.splice(index, 1) diff --git a/src/core/drive/view_transitioner.js b/src/core/drive/view_transitioner.js deleted file mode 100644 index f8663dbac..000000000 --- a/src/core/drive/view_transitioner.js +++ /dev/null @@ -1,21 +0,0 @@ -export class ViewTransitioner { - #viewTransitionStarted = false - #lastOperation = Promise.resolve() - - 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 - } - - get viewTransitionsAvailable() { - return document.startViewTransition - } -} diff --git a/src/core/drive/view_transitioner.ts b/src/core/drive/view_transitioner.ts new file mode 100644 index 000000000..b0ec16cce --- /dev/null +++ b/src/core/drive/view_transitioner.ts @@ -0,0 +1,31 @@ +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.js b/src/core/drive/visit.ts similarity index 74% rename from src/core/drive/visit.js rename to src/core/drive/visit.ts index 7fc494c19..1a311b640 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.ts @@ -1,55 +1,116 @@ -import { FetchMethod, FetchRequest } from "../../http/fetch_request" +import { Adapter } from "../native/adapter" +import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request" +import { FetchResponse } from "../../http/fetch_response" +import { History } from "./history" import { getAnchor } from "../url" +import { Snapshot } from "../snapshot" import { PageSnapshot } from "./page_snapshot" +import { Action } from "../types" import { getHistoryMethodForAction, uuid } from "../../util" +import { PageView } from "./page_view" import { StreamMessage } from "../streams/stream_message" import { ViewTransitioner } from "./view_transitioner" -const defaultOptions = { +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 = { action: "advance", historyChanged: false, visitCachedSnapshot: () => {}, willRender: true, updateHistory: true, shouldCacheSnapshot: true, - acceptsStreamResponse: false -} - -export const TimingMetric = { - visitStart: "visitStart", - requestStart: "requestStart", - requestEnd: "requestEnd", - visitEnd: "visitEnd" + acceptsStreamResponse: false, } -export const VisitState = { - initialized: "initialized", - started: "started", - canceled: "canceled", - failed: "failed", - completed: "completed" +export type VisitResponse = { + statusCode: number + redirected: boolean + responseHTML?: string } -export const SystemStatusCode = { - networkFailure: 0, - timeoutFailure: -1, - contentTypeMismatch: -2 +export enum SystemStatusCode { + networkFailure = 0, + timeoutFailure = -1, + contentTypeMismatch = -2, } -export class Visit { - identifier = uuid() // Required by turbo-ios - timingMetrics = {} +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 followedRedirect = false + frame?: number historyChanged = false + location: URL + isSamePage: boolean + redirectedToLocation?: URL + request?: FetchRequest + response?: VisitResponse scrolled = false shouldCacheSnapshot = true acceptsStreamResponse = false + snapshotHTML?: string snapshotCached = false state = VisitState.initialized + snapshot?: PageSnapshot viewTransitioner = new ViewTransitioner() - constructor(delegate, location, restorationIdentifier, options = {}) { + constructor( + delegate: VisitDelegate, + location: URL, + restorationIdentifier: string | undefined, + options: Partial = {} + ) { this.delegate = delegate this.location = location this.restorationIdentifier = restorationIdentifier || uuid() @@ -65,10 +126,10 @@ export class Visit { willRender, updateHistory, shouldCacheSnapshot, - acceptsStreamResponse + acceptsStreamResponse, } = { ...defaultOptions, - ...options + ...options, } this.action = action this.historyChanged = historyChanged @@ -263,7 +324,7 @@ export class Visit { action: "replace", response: this.response, shouldCacheSnapshot: false, - willRender: false + willRender: false, }) this.followedRedirect = true } @@ -282,7 +343,7 @@ export class Visit { // Fetch request delegate - prepareRequest(request) { + prepareRequest(request: FetchRequest) { if (this.acceptsStreamResponse) { request.acceptResponseType(StreamMessage.contentType) } @@ -292,15 +353,15 @@ export class Visit { this.startRequest() } - requestPreventedHandlingResponse(_request, _response) {} + requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) {} - async requestSucceededWithResponse(request, response) { + async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, - redirected + redirected, }) } else { this.redirectedToLocation = response.redirected ? response.location : undefined @@ -308,23 +369,23 @@ export class Visit { } } - async requestFailedWithResponse(request, response) { + async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, - redirected + redirected, }) } else { this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } - requestErrored(_request, _error) { + requestErrored(_request: FetchRequest, _error: Error) { this.recordResponse({ statusCode: SystemStatusCode.networkFailure, - redirected: false + redirected: false, }) } @@ -367,17 +428,17 @@ export class Visit { // Instrumentation - recordTimingMetric(metric) { + recordTimingMetric(metric: TimingMetric) { this.timingMetrics[metric] = new Date().getTime() } - getTimingMetrics() { + getTimingMetrics(): TimingMetrics { return { ...this.timingMetrics } } // Private - getHistoryMethodForAction(action) { + getHistoryMethodForAction(action: Action) { switch (action) { case "replace": return history.replaceState @@ -408,16 +469,16 @@ export class Visit { } } - async render(callback) { + async render(callback: () => Promise) { this.cancelRender() - await new Promise((resolve) => { + await new Promise((resolve) => { this.frame = requestAnimationFrame(() => resolve()) }) await callback() delete this.frame } - async renderPageSnapshot(snapshot, isPreview) { + async renderPageSnapshot(snapshot: PageSnapshot, isPreview: boolean) { await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { await this.view.renderPage(snapshot, isPreview, this.willRender, this) this.performScroll() @@ -432,6 +493,6 @@ export class Visit { } } -function isSuccessful(statusCode) { +function isSuccessful(statusCode: number) { return statusCode >= 200 && statusCode < 300 } diff --git a/src/core/errors.js b/src/core/errors.ts similarity index 100% rename from src/core/errors.js rename to src/core/errors.ts diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.ts similarity index 54% rename from src/core/frames/frame_controller.js rename to src/core/frames/frame_controller.ts index 6fe1dcbe0..9282a597c 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.ts @@ -1,7 +1,12 @@ -import { FrameElement, FrameLoadingStyle } from "../../elements/frame_element" -import { FetchMethod, FetchRequest } from "../../http/fetch_request" +import { + FrameElement, + FrameElementDelegate, + FrameLoadingStyle, + FrameElementObservedAttribute, +} from "../../elements/frame_element" +import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" -import { AppearanceObserver } from "../../observers/appearance_observer" +import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer" import { clearBusyState, dispatch, @@ -10,31 +15,58 @@ import { markAsBusy, uuid, getHistoryMethodForAction, - getVisitAction + getVisitAction, } from "../../util" -import { FormSubmission } from "../drive/form_submission" +import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" import { Snapshot } from "../snapshot" -import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" -import { FormSubmitObserver } from "../../observers/form_submit_observer" +import { ViewDelegate, ViewRenderOptions } from "../view" +import { Locatable, getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" import { FrameView } from "./frame_view" -import { LinkInterceptor } from "./link_interceptor" -import { FormLinkClickObserver } from "../../observers/form_link_click_observer" +import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" +import { FormLinkClickObserver, FormLinkClickObserverDelegate } 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" -export class FrameController { - fetchResponseLoaded = (_fetchResponse) => {} - #currentFetchRequest = null - #resolveVisitPromise = () => {} - #connected = false - #hasBeenLoaded = false - #ignoredAttributes = new Set() - action = null - - constructor(element) { +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) { this.element = element this.view = new FrameView(this, this.element) this.appearanceObserver = new AppearanceObserver(this, this.element) @@ -44,15 +76,13 @@ export class FrameController { this.formSubmitObserver = new FormSubmitObserver(this, this.element) } - // Frame delegate - connect() { - if (!this.#connected) { - this.#connected = true + if (!this.connected) { + this.connected = true if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() } else { - this.#loadSourceURL() + this.loadSourceURL() } this.formLinkClickObserver.start() this.linkInterceptor.start() @@ -61,8 +91,8 @@ export class FrameController { } disconnect() { - if (this.#connected) { - this.#connected = false + if (this.connected) { + this.connected = false this.appearanceObserver.stop() this.formLinkClickObserver.stop() this.linkInterceptor.stop() @@ -72,25 +102,25 @@ export class FrameController { disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { - this.#loadSourceURL() + this.loadSourceURL() } } sourceURLChanged() { - if (this.#isIgnoringChangesTo("src")) return + if (this.isIgnoringChangesTo("src")) return if (this.element.isConnected) { this.complete = false } - if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { - this.#loadSourceURL() + if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { + this.loadSourceURL() } } sourceURLReloaded() { const { src } = this.element - this.#ignoringChangesToAttribute("complete", () => { + this.ignoringChangesToAttribute("complete", () => { this.element.removeAttribute("complete") }) this.element.src = null @@ -99,9 +129,9 @@ export class FrameController { } completeChanged() { - if (this.#isIgnoringChangesTo("complete")) return + if (this.isIgnoringChangesTo("complete")) return - this.#loadSourceURL() + this.loadSourceURL() } loadingStyleChanged() { @@ -109,20 +139,20 @@ export class FrameController { this.appearanceObserver.start() } else { this.appearanceObserver.stop() - this.#loadSourceURL() + this.loadSourceURL() } } - async #loadSourceURL() { + private async loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { - this.element.loaded = this.#visit(expandURL(this.sourceURL)) + this.element.loaded = this.visit(expandURL(this.sourceURL)) this.appearanceObserver.stop() await this.element.loaded - this.#hasBeenLoaded = true + this.hasBeenLoaded = true } } - async loadResponse(fetchResponse) { + async loadResponse(fetchResponse: FetchResponse) { if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } @@ -134,9 +164,9 @@ export class FrameController { const pageSnapshot = PageSnapshot.fromDocument(document) if (pageSnapshot.isVisitable) { - await this.#loadFrameResponse(fetchResponse, document) + await this.loadFrameResponse(fetchResponse, document) } else { - await this.#handleUnvisitableFrameResponse(fetchResponse) + await this.handleUnvisitableFrameResponse(fetchResponse) } } } finally { @@ -146,39 +176,39 @@ export class FrameController { // Appearance observer delegate - elementAppearedInViewport(element) { + elementAppearedInViewport(element: FrameElement) { this.proposeVisitIfNavigatedWithAction(element, element) - this.#loadSourceURL() + this.loadSourceURL() } // Form link click observer delegate - willSubmitFormLinkToLocation(link) { - return this.#shouldInterceptNavigation(link) + willSubmitFormLinkToLocation(link: Element): boolean { + return this.shouldInterceptNavigation(link) } - submittedFormLinkToLocation(link, _location, form) { - const frame = this.#findFrameElement(link) + submittedFormLinkToLocation(link: Element, _location: URL, form: HTMLFormElement): void { + const frame = this.findFrameElement(link) if (frame) form.setAttribute("data-turbo-frame", frame.id) } // Link interceptor delegate - shouldInterceptLinkClick(element, _location, _event) { - return this.#shouldInterceptNavigation(element) + shouldInterceptLinkClick(element: Element, _location: string, _event: MouseEvent) { + return this.shouldInterceptNavigation(element) } - linkClickIntercepted(element, location) { - this.#navigateFrame(element, location) + linkClickIntercepted(element: Element, location: string) { + this.navigateFrame(element, location) } // Form submit observer delegate - willSubmitForm(element, submitter) { - return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter) + willSubmitForm(element: HTMLFormElement, submitter?: HTMLElement) { + return element.closest("turbo-frame") == this.element && this.shouldInterceptNavigation(element, submitter) } - formSubmitted(element, submitter) { + formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { if (this.formSubmission) { this.formSubmission.stop() } @@ -191,7 +221,7 @@ export class FrameController { // Fetch request delegate - prepareRequest(request) { + prepareRequest(request: FetchRequest) { request.headers["Turbo-Frame"] = this.id if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { @@ -199,41 +229,41 @@ export class FrameController { } } - requestStarted(_request) { + requestStarted(_request: FetchRequest) { markAsBusy(this.element) } - requestPreventedHandlingResponse(_request, _response) { - this.#resolveVisitPromise() + requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) { + this.resolveVisitPromise() } - async requestSucceededWithResponse(request, response) { + async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { await this.loadResponse(response) - this.#resolveVisitPromise() + this.resolveVisitPromise() } - async requestFailedWithResponse(request, response) { + async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { await this.loadResponse(response) - this.#resolveVisitPromise() + this.resolveVisitPromise() } - requestErrored(request, error) { + requestErrored(request: FetchRequest, error: Error) { console.error(error) - this.#resolveVisitPromise() + this.resolveVisitPromise() } - requestFinished(_request) { + requestFinished(_request: FetchRequest) { clearBusyState(this.element) } // Form submission delegate - formSubmissionStarted({ formElement }) { - markAsBusy(formElement, this.#findFrameElement(formElement)) + formSubmissionStarted({ formElement }: FormSubmission) { + markAsBusy(formElement, this.findFrameElement(formElement)) } - formSubmissionSucceededWithResponse(formSubmission, response) { - const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter) + formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) { + const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter) frame.delegate.loadResponse(response) @@ -243,30 +273,34 @@ export class FrameController { } } - formSubmissionFailedWithResponse(formSubmission, fetchResponse) { + formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { this.element.delegate.loadResponse(fetchResponse) session.clearCache() } - formSubmissionErrored(formSubmission, error) { + formSubmissionErrored(formSubmission: FormSubmission, error: Error) { console.error(error) } - formSubmissionFinished({ formElement }) { - clearBusyState(formElement, this.#findFrameElement(formElement)) + formSubmissionFinished({ formElement }: FormSubmission) { + clearBusyState(formElement, this.findFrameElement(formElement)) } // View delegate - allowsImmediateRender({ element: newFrame }, _isPreview, options) { - const event = dispatch("turbo:before-frame-render", { + allowsImmediateRender( + { element: newFrame }: Snapshot, + _isPreview: boolean, + options: ViewRenderOptions + ) { + const event = dispatch("turbo:before-frame-render", { target: this.element, detail: { newFrame, ...options }, - cancelable: true + cancelable: true, }) const { defaultPrevented, - detail: { render } + detail: { render }, } = event if (this.view.renderer && render) { @@ -276,21 +310,20 @@ export class FrameController { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, _isPreview) {} + viewRenderedSnapshot(_snapshot: Snapshot, _isPreview: boolean) {} - preloadOnLoadLinksForView(element) { + preloadOnLoadLinksForView(element: Element) { session.preloadOnLoadLinksForView(element) } viewInvalidated() {} // Frame renderer delegate - - willRenderFrame(currentElement, _newElement) { + willRenderFrame(currentElement: FrameElement, _newElement: FrameElement) { this.previousFrameElement = currentElement.cloneNode(true) } - visitCachedSnapshot = ({ element }) => { + visitCachedSnapshot = ({ element }: Snapshot) => { const frame = element.querySelector("#" + this.element.id) if (frame && this.previousFrameElement) { @@ -302,7 +335,7 @@ export class FrameController { // Private - async #loadFrameResponse(fetchResponse, document) { + private async loadFrameResponse(fetchResponse: FetchResponse, document: Document) { const newFrameElement = await this.extractForeignFrameElement(document.body) if (newFrameElement) { @@ -316,56 +349,56 @@ export class FrameController { session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) this.fetchResponseLoaded(fetchResponse) - } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { - this.#handleFrameMissingFromResponse(fetchResponse) + } else if (this.willHandleFrameMissingFromResponse(fetchResponse)) { + this.handleFrameMissingFromResponse(fetchResponse) } } - async #visit(url) { + private async visit(url: URL) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) - this.#currentFetchRequest?.cancel() - this.#currentFetchRequest = request + this.currentFetchRequest?.cancel() + this.currentFetchRequest = request - return new Promise((resolve) => { - this.#resolveVisitPromise = () => { - this.#resolveVisitPromise = () => {} - this.#currentFetchRequest = null + return new Promise((resolve) => { + this.resolveVisitPromise = () => { + this.resolveVisitPromise = () => {} + this.currentFetchRequest = null resolve() } request.perform() }) } - #navigateFrame(element, url, submitter) { - const frame = this.#findFrameElement(element, submitter) + private navigateFrame(element: Element, url: string, submitter?: HTMLElement) { + const frame = this.findFrameElement(element, submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter) - this.#withCurrentNavigationElement(element, () => { + this.withCurrentNavigationElement(element, () => { frame.src = url }) } - proposeVisitIfNavigatedWithAction(frame, element, submitter) { + proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) { this.action = getVisitAction(submitter, element, frame) if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone() const { visitCachedSnapshot } = frame.delegate - frame.delegate.fetchResponseLoaded = (fetchResponse) => { + frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse const responseHTML = frame.ownerDocument.documentElement.outerHTML const response = { statusCode, redirected, responseHTML } - const options = { + const options: Partial = { response, visitCachedSnapshot, willRender: false, updateHistory: false, restorationIdentifier: this.restorationIdentifier, - snapshot: pageSnapshot + snapshot: pageSnapshot, } if (this.action) options.action = this.action @@ -383,46 +416,46 @@ export class FrameController { } } - async #handleUnvisitableFrameResponse(fetchResponse) { + private async handleUnvisitableFrameResponse(fetchResponse: FetchResponse) { console.warn( `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` ) - await this.#visitResponse(fetchResponse.response) + await this.visitResponse(fetchResponse.response) } - #willHandleFrameMissingFromResponse(fetchResponse) { + private willHandleFrameMissingFromResponse(fetchResponse: FetchResponse): boolean { this.element.setAttribute("complete", "") const response = fetchResponse.response - const visit = async (url, options) => { + const visit = async (url: Locatable | Response, options: Partial = {}) => { if (url instanceof Response) { - this.#visitResponse(url) + this.visitResponse(url) } else { session.visit(url, options) } } - const event = dispatch("turbo:frame-missing", { + const event = dispatch("turbo:frame-missing", { target: this.element, detail: { response, visit }, - cancelable: true + cancelable: true, }) return !event.defaultPrevented } - #handleFrameMissingFromResponse(fetchResponse) { + private handleFrameMissingFromResponse(fetchResponse: FetchResponse) { this.view.missing() - this.#throwFrameMissingError(fetchResponse) + this.throwFrameMissingError(fetchResponse) } - #throwFrameMissingError(fetchResponse) { + private throwFrameMissingError(fetchResponse: FetchResponse) { const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.` throw new TurboFrameMissingError(message) } - async #visitResponse(response) { + private async visitResponse(response: Response): Promise { const wrapped = new FetchResponse(response) const responseHTML = await wrapped.responseHTML const { location, redirected, statusCode } = wrapped @@ -430,12 +463,12 @@ export class FrameController { return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } - #findFrameElement(element, submitter) { + private findFrameElement(element: Element, submitter?: HTMLElement) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") return getFrameElementById(id) ?? this.element } - async extractForeignFrameElement(container) { + async extractForeignFrameElement(container: ParentNode): Promise { let element const id = CSS.escape(this.id) @@ -458,16 +491,16 @@ export class FrameController { return null } - #formActionIsVisitable(form, submitter) { + private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) { const action = getAction(form, submitter) return locationIsVisitable(expandURL(action), this.rootLocation) } - #shouldInterceptNavigation(element, submitter) { + private shouldInterceptNavigation(element: Element, submitter?: HTMLElement) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") - if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { + if (element instanceof HTMLFormElement && !this.formActionIsVisitable(element, submitter)) { return false } @@ -509,8 +542,8 @@ export class FrameController { } } - set sourceURL(sourceURL) { - this.#ignoringChangesToAttribute("src", () => { + set sourceURL(sourceURL: string | undefined) { + this.ignoringChangesToAttribute("src", () => { this.element.src = sourceURL ?? null }) } @@ -520,15 +553,15 @@ export class FrameController { } get isLoading() { - return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined + return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined } get complete() { return this.element.hasAttribute("complete") } - set complete(value) { - this.#ignoringChangesToAttribute("complete", () => { + set complete(value: boolean) { + this.ignoringChangesToAttribute("complete", () => { if (value) { this.element.setAttribute("complete", "") } else { @@ -538,33 +571,33 @@ export class FrameController { } get isActive() { - return this.element.isActive && this.#connected + return this.element.isActive && this.connected } 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) } - #isIgnoringChangesTo(attributeName) { - return this.#ignoredAttributes.has(attributeName) + private isIgnoringChangesTo(attributeName: FrameElementObservedAttribute): boolean { + return this.ignoredAttributes.has(attributeName) } - #ignoringChangesToAttribute(attributeName, callback) { - this.#ignoredAttributes.add(attributeName) + private ignoringChangesToAttribute(attributeName: FrameElementObservedAttribute, callback: () => void) { + this.ignoredAttributes.add(attributeName) callback() - this.#ignoredAttributes.delete(attributeName) + this.ignoredAttributes.delete(attributeName) } - #withCurrentNavigationElement(element, callback) { + private withCurrentNavigationElement(element: Element, callback: () => void) { this.currentNavigationElement = element callback() delete this.currentNavigationElement } } -function getFrameElementById(id) { +function getFrameElementById(id: string | null) { if (id != null) { const element = document.getElementById(id) if (element instanceof FrameElement) { @@ -573,7 +606,7 @@ function getFrameElementById(id) { } } -function activateElement(element, currentURL) { +function activateElement(element: Element | null, currentURL?: string | null) { if (element) { const src = element.getAttribute("src") if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { diff --git a/src/core/frames/frame_redirector.js b/src/core/frames/frame_redirector.js deleted file mode 100644 index 10aeee6cc..000000000 --- a/src/core/frames/frame_redirector.js +++ /dev/null @@ -1,85 +0,0 @@ -import { FormSubmitObserver } from "../../observers/form_submit_observer" -import { FrameElement } from "../../elements/frame_element" -import { LinkInterceptor } from "./link_interceptor" -import { expandURL, getAction, locationIsVisitable } from "../url" - -export class FrameRedirector { - constructor(session, element) { - this.session = session - this.element = element - this.linkInterceptor = new LinkInterceptor(this, element) - this.formSubmitObserver = new FormSubmitObserver(this, element) - } - - start() { - this.linkInterceptor.start() - this.formSubmitObserver.start() - } - - stop() { - this.linkInterceptor.stop() - this.formSubmitObserver.stop() - } - - // Link interceptor delegate - - shouldInterceptLinkClick(element, _location, _event) { - return this.#shouldRedirect(element) - } - - linkClickIntercepted(element, url, event) { - const frame = this.#findFrameElement(element) - if (frame) { - frame.delegate.linkClickIntercepted(element, url, event) - } - } - - // Form submit observer delegate - - willSubmitForm(element, submitter) { - return ( - element.closest("turbo-frame") == null && - this.#shouldSubmit(element, submitter) && - this.#shouldRedirect(element, submitter) - ) - } - - formSubmitted(element, submitter) { - const frame = this.#findFrameElement(element, submitter) - if (frame) { - frame.delegate.formSubmitted(element, submitter) - } - } - - #shouldSubmit(form, submitter) { - const action = getAction(form, submitter) - const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) - const rootLocation = expandURL(meta?.content ?? "/") - - return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) - } - - #shouldRedirect(element, submitter) { - const isNavigatable = - element instanceof HTMLFormElement - ? this.session.submissionIsNavigatable(element, submitter) - : this.session.elementIsNavigatable(element) - - if (isNavigatable) { - const frame = this.#findFrameElement(element, submitter) - return frame ? frame != element.closest("turbo-frame") : false - } else { - return false - } - } - - #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])`) - if (frame instanceof FrameElement) { - return frame - } - } - } -} diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.ts new file mode 100644 index 000000000..3175e9fe0 --- /dev/null +++ b/src/core/frames/frame_redirector.ts @@ -0,0 +1,86 @@ +import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" +import { FrameElement } from "../../elements/frame_element" +import { LinkInterceptor, LinkInterceptorDelegate } 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) { + this.session = session + this.element = element + this.linkInterceptor = new LinkInterceptor(this, element) + this.formSubmitObserver = new FormSubmitObserver(this, element) + } + + start() { + this.linkInterceptor.start() + this.formSubmitObserver.start() + } + + stop() { + this.linkInterceptor.stop() + this.formSubmitObserver.stop() + } + + shouldInterceptLinkClick(element: Element, _location: string, _event: MouseEvent) { + return this.shouldRedirect(element) + } + + linkClickIntercepted(element: Element, url: string, event: MouseEvent) { + const frame = this.findFrameElement(element) + if (frame) { + frame.delegate.linkClickIntercepted(element, url, event) + } + } + + willSubmitForm(element: HTMLFormElement, submitter?: HTMLElement) { + return ( + element.closest("turbo-frame") == null && + this.shouldSubmit(element, submitter) && + this.shouldRedirect(element, submitter) + ) + } + + formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { + const frame = this.findFrameElement(element, submitter) + if (frame) { + frame.delegate.formSubmitted(element, submitter) + } + } + + private shouldSubmit(form: HTMLFormElement, submitter?: HTMLElement) { + const action = getAction(form, submitter) + const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) + const rootLocation = expandURL(meta?.content ?? "/") + + return this.shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) + } + + private shouldRedirect(element: Element, submitter?: HTMLElement) { + const isNavigatable = + element instanceof HTMLFormElement + ? this.session.submissionIsNavigatable(element, submitter) + : this.session.elementIsNavigatable(element) + + if (isNavigatable) { + const frame = this.findFrameElement(element, submitter) + return frame ? frame != element.closest("turbo-frame") : false + } else { + return false + } + } + + private findFrameElement(element: Element, submitter?: HTMLElement) { + const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame") + if (id && id != "_top") { + const frame = this.element.querySelector(`#${id}:not([disabled])`) + if (frame instanceof FrameElement) { + return frame + } + } + } +} diff --git a/src/core/frames/frame_renderer.js b/src/core/frames/frame_renderer.ts similarity index 70% rename from src/core/frames/frame_renderer.js rename to src/core/frames/frame_renderer.ts index 1384465d6..4f9a17253 100644 --- a/src/core/frames/frame_renderer.js +++ b/src/core/frames/frame_renderer.ts @@ -1,8 +1,16 @@ +import { FrameElement } from "../../elements/frame_element" import { activateScriptElement, nextAnimationFrame } from "../../util" -import { Renderer } from "../renderer" +import { Render, Renderer } from "../renderer" +import { Snapshot } from "../snapshot" -export class FrameRenderer extends Renderer { - static renderElement(currentElement, newElement) { +export interface FrameRendererDelegate { + willRenderFrame(currentElement: FrameElement, newElement: FrameElement): void +} + +export class FrameRenderer extends Renderer { + private readonly delegate: FrameRendererDelegate + + static renderElement(currentElement: FrameElement, newElement: FrameElement) { const destinationRange = document.createRange() destinationRange.selectNodeContents(currentElement) destinationRange.deleteContents() @@ -15,7 +23,14 @@ export class FrameRenderer extends Renderer { } } - constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + constructor( + delegate: FrameRendererDelegate, + currentSnapshot: Snapshot, + newSnapshot: Snapshot, + renderElement: Render, + isPreview: boolean, + willRender = true + ) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate } @@ -67,7 +82,7 @@ export class FrameRenderer extends Renderer { } } -function readScrollLogicalPosition(value, defaultValue) { +function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLogicalPosition): ScrollLogicalPosition { if (value == "end" || value == "start" || value == "center" || value == "nearest") { return value } else { @@ -75,7 +90,7 @@ function readScrollLogicalPosition(value, defaultValue) { } } -function readScrollBehavior(value, defaultValue) { +function readScrollBehavior(value: string | null, defaultValue: ScrollBehavior): ScrollBehavior { if (value == "auto" || value == "smooth") { return value } else { diff --git a/src/core/frames/frame_view.js b/src/core/frames/frame_view.js deleted file mode 100644 index e65f64a77..000000000 --- a/src/core/frames/frame_view.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Snapshot } from "../snapshot" -import { View } from "../view" - -export class FrameView extends View { - missing() { - this.element.innerHTML = `Content missing` - } - - get snapshot() { - return new Snapshot(this.element) - } -} diff --git a/src/core/frames/frame_view.ts b/src/core/frames/frame_view.ts new file mode 100644 index 000000000..b19714f35 --- /dev/null +++ b/src/core/frames/frame_view.ts @@ -0,0 +1,15 @@ +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.ts similarity index 65% rename from src/core/frames/link_interceptor.js rename to src/core/frames/link_interceptor.ts index 9b9249544..8f2e13f40 100644 --- a/src/core/frames/link_interceptor.js +++ b/src/core/frames/link_interceptor.ts @@ -1,5 +1,16 @@ +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 { - constructor(delegate, element) { + readonly delegate: LinkInterceptorDelegate + readonly element: Element + private clickEvent?: Event + + constructor(delegate: LinkInterceptorDelegate, element: Element) { this.delegate = delegate this.element = element } @@ -16,7 +27,7 @@ export class LinkInterceptor { document.removeEventListener("turbo:before-visit", this.willVisit) } - clickBubbled = (event) => { + clickBubbled = (event: Event) => { if (this.respondsToEventTarget(event.target)) { this.clickEvent = event } else { @@ -24,7 +35,7 @@ export class LinkInterceptor { } } - linkClicked = (event) => { + 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() @@ -33,13 +44,13 @@ export class LinkInterceptor { } } delete this.clickEvent - } + }) - willVisit = (_event) => { + willVisit = ((_event: TurboBeforeVisitEvent) => { delete this.clickEvent - } + }) - respondsToEventTarget(target) { + 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.ts similarity index 67% rename from src/core/index.js rename to src/core/index.ts index 2a3dd8fe5..7b7507c3f 100644 --- a/src/core/index.js +++ b/src/core/index.ts @@ -1,5 +1,10 @@ -import { Session } from "./session" +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" @@ -9,8 +14,24 @@ 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. @@ -26,7 +47,7 @@ export function start() { * * @param adapter Adapter to register */ -export function registerAdapter(adapter) { +export function registerAdapter(adapter: Adapter) { session.registerAdapter(adapter) } @@ -44,7 +65,7 @@ export function registerAdapter(adapter) { * @param options.snapshotHTML Cached snapshot to render * @param options.response Response of the specified location */ -export function visit(location, options) { +export function visit(location: Locatable, options?: Partial) { session.visit(location, options) } @@ -53,7 +74,7 @@ export function visit(location, options) { * * @param source Stream source to connect */ -export function connectStreamSource(source) { +export function connectStreamSource(source: StreamSource) { session.connectStreamSource(source) } @@ -62,7 +83,7 @@ export function connectStreamSource(source) { * * @param source Stream source to disconnect */ -export function disconnectStreamSource(source) { +export function disconnectStreamSource(source: StreamSource) { session.disconnectStreamSource(source) } @@ -72,7 +93,7 @@ export function disconnectStreamSource(source) { * * @param message Message to render */ -export function renderStreamMessage(message) { +export function renderStreamMessage(message: StreamMessage | string) { session.renderStreamMessage(message) } @@ -99,14 +120,16 @@ export function clearCache() { * * @param delay Time to delay in milliseconds */ -export function setProgressBarDelay(delay) { +export function setProgressBarDelay(delay: number) { session.setProgressBarDelay(delay) } -export function setConfirmMethod(confirmMethod) { +export function setConfirmMethod( + confirmMethod: (message: string, element: HTMLFormElement, submitter: HTMLElement | undefined) => Promise +) { FormSubmission.confirmMethod = confirmMethod } -export function setFormMode(mode) { +export function setFormMode(mode: FormMode) { session.setFormMode(mode) } diff --git a/src/core/native/adapter.ts b/src/core/native/adapter.ts new file mode 100644 index 000000000..3ecfd9b22 --- /dev/null +++ b/src/core/native/adapter.ts @@ -0,0 +1,18 @@ +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.js b/src/core/native/browser_adapter.ts similarity index 64% rename from src/core/native/browser_adapter.js rename to src/core/native/browser_adapter.ts index 7505d7f8f..ca268e341 100644 --- a/src/core/native/browser_adapter.js +++ b/src/core/native/browser_adapter.ts @@ -1,26 +1,40 @@ +import { Adapter } from "./adapter" import { ProgressBar } from "../drive/progress_bar" -import { SystemStatusCode } from "../drive/visit" +import { SystemStatusCode, Visit, VisitOptions } from "../drive/visit" +import { FormSubmission } from "../drive/form_submission" +import { Session } from "../session" import { uuid, dispatch } from "../../util" -export class BrowserAdapter { - progressBar = new ProgressBar() +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() - constructor(session) { + visitProgressBarTimeout?: number + formProgressBarTimeout?: number + location?: URL + + constructor(session: Session) { this.session = session } - visitProposedToLocation(location, options) { + visitProposedToLocation(location: URL, options?: Partial) { this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options) } - visitStarted(visit) { + visitStarted(visit: Visit) { this.location = visit.location visit.loadCachedSnapshot() visit.issueRequest() visit.goToSamePageAnchor() } - visitRequestStarted(visit) { + visitRequestStarted(visit: Visit) { this.progressBar.setValue(0) if (visit.hasCachedSnapshot() || visit.action != "restore") { this.showVisitProgressBarAfterDelay() @@ -29,11 +43,11 @@ export class BrowserAdapter { } } - visitRequestCompleted(visit) { + visitRequestCompleted(visit: Visit) { visit.loadResponse() } - visitRequestFailedWithStatusCode(visit, statusCode) { + visitRequestFailedWithStatusCode(visit: Visit, statusCode: number) { switch (statusCode) { case SystemStatusCode.networkFailure: case SystemStatusCode.timeoutFailure: @@ -41,37 +55,35 @@ export class BrowserAdapter { return this.reload({ reason: "request_failed", context: { - statusCode - } + statusCode, + }, }) default: return visit.loadResponse() } } - visitRequestFinished(_visit) { + visitRequestFinished(_visit: Visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } - visitCompleted(_visit) {} + visitCompleted(_visit: Visit) {} - pageInvalidated(reason) { + pageInvalidated(reason: ReloadReason) { this.reload(reason) } - visitFailed(_visit) {} - - visitRendered(_visit) {} + visitFailed(_visit: Visit) {} - // Form Submission Delegate + visitRendered(_visit: Visit) {} - formSubmissionStarted(_formSubmission) { + formSubmissionStarted(_formSubmission: FormSubmission) { this.progressBar.setValue(0) this.showFormProgressBarAfterDelay() } - formSubmissionFinished(_formSubmission) { + formSubmissionFinished(_formSubmission: FormSubmission) { this.progressBar.setValue(1) this.hideFormProgressBar() } @@ -108,7 +120,7 @@ export class BrowserAdapter { this.progressBar.show() } - reload(reason) { + reload(reason: ReloadReason) { dispatch("turbo:reload", { detail: reason }) window.location.href = this.location?.toString() || window.location.href diff --git a/src/core/renderer.js b/src/core/renderer.js deleted file mode 100644 index 12d87927e..000000000 --- a/src/core/renderer.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Bardo } from "./bardo" - -export class Renderer { - #activeElement = null - - constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { - this.currentSnapshot = currentSnapshot - this.newSnapshot = newSnapshot - this.isPreview = isPreview - this.willRender = willRender - this.renderElement = renderElement - this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) - } - - get shouldRender() { - return true - } - - get reloadReason() { - return - } - - prepareToRender() { - return - } - - render() { - // Abstract method - } - - finishRendering() { - if (this.resolvingFunctions) { - this.resolvingFunctions.resolve() - delete this.resolvingFunctions - } - } - - async preservingPermanentElements(callback) { - await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback) - } - - focusFirstAutofocusableElement() { - const element = this.connectedSnapshot.firstAutofocusableElement - if (elementIsFocusable(element)) { - element.focus() - } - } - - // Bardo delegate - - enteringBardo(currentPermanentElement) { - if (this.#activeElement) return - - if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { - this.#activeElement = this.currentSnapshot.activeElement - } - } - - leavingBardo(currentPermanentElement) { - if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { - this.#activeElement.focus() - - this.#activeElement = null - } - } - - get connectedSnapshot() { - return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot - } - - get currentElement() { - return this.currentSnapshot.element - } - - get newElement() { - return this.newSnapshot.element - } - - get permanentElementMap() { - return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) - } -} - -function elementIsFocusable(element) { - return element && typeof element.focus == "function" -} diff --git a/src/core/renderer.ts b/src/core/renderer.ts new file mode 100644 index 000000000..e77be9d34 --- /dev/null +++ b/src/core/renderer.ts @@ -0,0 +1,100 @@ +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) { + this.currentSnapshot = currentSnapshot + this.newSnapshot = newSnapshot + this.isPreview = isPreview + this.willRender = willRender + this.renderElement = renderElement + this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) + } + + get shouldRender() { + return true + } + + get reloadReason(): ReloadReason { + return + } + + prepareToRender() { + return + } + + abstract render(): Promise + + finishRendering() { + if (this.resolvingFunctions) { + this.resolvingFunctions.resolve() + delete this.resolvingFunctions + } + } + + async preservingPermanentElements(callback: () => void) { + await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback) + } + + focusFirstAutofocusableElement() { + const element = this.connectedSnapshot.firstAutofocusableElement + if (elementIsFocusable(element)) { + element.focus() + } + } + + // Bardo delegate + + enteringBardo(currentPermanentElement: Element) { + if (this.activeElement) return + + if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { + this.activeElement = this.currentSnapshot.activeElement + } + } + + leavingBardo(currentPermanentElement: Element) { + if (currentPermanentElement.contains(this.activeElement) && this.activeElement instanceof HTMLElement) { + this.activeElement.focus() + + this.activeElement = null + } + } + + get connectedSnapshot() { + return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot + } + + get currentElement() { + return this.currentSnapshot.element + } + + get newElement() { + return this.newSnapshot.element + } + + get permanentElementMap() { + return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) + } +} + +function elementIsFocusable(element: any): element is { focus: () => void } { + return element && typeof element.focus == "function" +} diff --git a/src/core/session.js b/src/core/session.ts similarity index 54% rename from src/core/session.js rename to src/core/session.ts index 44edc7856..31eab98f4 100644 --- a/src/core/session.js +++ b/src/core/session.ts @@ -1,44 +1,75 @@ -import { BrowserAdapter } from "./native/browser_adapter" +import { Adapter } from "./native/adapter" +import { BrowserAdapter, ReloadReason } from "./native/browser_adapter" import { CacheObserver } from "../observers/cache_observer" -import { FormSubmitObserver } from "../observers/form_submit_observer" +import { FormSubmitObserver, FormSubmitObserverDelegate } 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 { 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 } from "./drive/page_view" +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 { Preloader } from "./drive/preloader" - -export class Session { - navigator = new Navigator(this) - history = new History(this) - preloader = new Preloader(this) - view = new PageView(this, document.documentElement) - adapter = new BrowserAdapter(this) - - pageObserver = new PageObserver(this) - cacheObserver = new CacheObserver() - linkClickObserver = new LinkClickObserver(this, window) - formSubmitObserver = new FormSubmitObserver(this, document) - scrollObserver = new ScrollObserver(this) - streamObserver = new StreamObserver(this) - formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) - frameRedirector = new FrameRedirector(this, document.documentElement) - streamMessageRenderer = new StreamMessageRenderer() +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 = "on" + formMode: FormMode = "on" start() { if (!this.started) { @@ -76,11 +107,11 @@ export class Session { } } - registerAdapter(adapter) { + registerAdapter(adapter: Adapter) { this.adapter = adapter } - visit(location, options = {}) { + visit(location: Locatable, options: Partial = {}) { const frameElement = options.frame ? document.getElementById(options.frame) : null if (frameElement instanceof FrameElement) { @@ -91,15 +122,15 @@ export class Session { } } - connectStreamSource(source) { + connectStreamSource(source: StreamSource) { this.streamObserver.connectStreamSource(source) } - disconnectStreamSource(source) { + disconnectStreamSource(source: StreamSource) { this.streamObserver.disconnectStreamSource(source) } - renderStreamMessage(message) { + renderStreamMessage(message: StreamMessage | string) { this.streamMessageRenderer.render(StreamMessage.wrap(message)) } @@ -107,11 +138,11 @@ export class Session { this.view.clearSnapshotCache() } - setProgressBarDelay(delay) { + setProgressBarDelay(delay: number) { this.progressBarDelay = delay } - setFormMode(mode) { + setFormMode(mode: FormMode) { this.formMode = mode } @@ -125,28 +156,28 @@ export class Session { // History delegate - historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier) { + historyPoppedToLocationWithRestorationIdentifier(location: URL, restorationIdentifier: string) { if (this.enabled) { this.navigator.startVisit(location, restorationIdentifier, { action: "restore", - historyChanged: true + historyChanged: true, }) } else { this.adapter.pageInvalidated({ - reason: "turbo_disabled" + reason: "turbo_disabled", }) } } // Scroll observer delegate - scrollPositionChanged(position) { + scrollPositionChanged(position: Position) { this.history.updateRestorationData({ scrollPosition: position }) } // Form click observer delegate - willSubmitFormLinkToLocation(link, location) { + willSubmitFormLinkToLocation(link: Element, location: URL): boolean { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) } @@ -154,7 +185,7 @@ export class Session { // Link click observer delegate - willFollowLinkToLocation(link, location, event) { + willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent) { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && @@ -162,7 +193,7 @@ export class Session { ) } - followedLinkToLocation(link, location) { + followedLinkToLocation(link: Element, location: URL) { const action = this.getActionForLink(link) const acceptsStreamResponse = link.hasAttribute("data-turbo-stream") @@ -171,18 +202,16 @@ export class Session { // Navigator delegate - allowsVisitingLocationWithAction(location, action) { + allowsVisitingLocationWithAction(location: URL, action?: Action) { return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) } - visitProposedToLocation(location, options) { + visitProposedToLocation(location: URL, options: Partial) { extendURLWithDeprecatedProperties(location) this.adapter.visitProposedToLocation(location, options) } - // Visit delegate - - visitStarted(visit) { + visitStarted(visit: Visit) { if (!visit.acceptsStreamResponse) { markAsBusy(document.documentElement) } @@ -192,22 +221,22 @@ export class Session { } } - visitCompleted(visit) { + visitCompleted(visit: Visit) { clearBusyState(document.documentElement) this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) } - locationWithActionIsSamePage(location, action) { + locationWithActionIsSamePage(location: URL, action?: Action): boolean { return this.navigator.locationWithActionIsSamePage(location, action) } - visitScrolledToSamePageLocation(oldURL, newURL) { + visitScrolledToSamePageLocation(oldURL: URL, newURL: URL) { this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) } // Form submit observer delegate - willSubmitForm(form, submitter) { + willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean { const action = getAction(form, submitter) return ( @@ -216,7 +245,7 @@ export class Session { ) } - formSubmitted(form, submitter) { + formSubmitted(form: HTMLFormElement, submitter?: HTMLElement) { this.navigator.submitForm(form, submitter) } @@ -237,7 +266,7 @@ export class Session { // Stream observer delegate - receivedMessageFromStream(message) { + receivedMessageFromStream(message: StreamMessage) { this.renderStreamMessage(message) } @@ -249,11 +278,11 @@ export class Session { } } - allowsImmediateRender({ element }, isPreview, options) { + allowsImmediateRender({ element }: PageSnapshot, isPreview: boolean, options: PageViewRenderOptions) { const event = this.notifyApplicationBeforeRender(element, isPreview, options) const { defaultPrevented, - detail: { render } + detail: { render }, } = event if (this.view.renderer && render) { @@ -263,105 +292,105 @@ export class Session { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, isPreview) { + viewRenderedSnapshot(_snapshot: PageSnapshot, isPreview: boolean) { this.view.lastRenderedLocation = this.history.location this.notifyApplicationAfterRender(isPreview) } - preloadOnLoadLinksForView(element) { + preloadOnLoadLinksForView(element: Element) { this.preloader.preloadOnLoadLinksForView(element) } - viewInvalidated(reason) { + viewInvalidated(reason: ReloadReason) { this.adapter.pageInvalidated(reason) } // Frame element - frameLoaded(frame) { + frameLoaded(frame: FrameElement) { this.notifyApplicationAfterFrameLoad(frame) } - frameRendered(fetchResponse, frame) { + frameRendered(fetchResponse: FetchResponse, frame: FrameElement) { this.notifyApplicationAfterFrameRender(fetchResponse, frame) } // Application events - applicationAllowsFollowingLinkToLocation(link, location, ev) { + applicationAllowsFollowingLinkToLocation(link: Element, location: URL, ev: MouseEvent) { const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) return !event.defaultPrevented } - applicationAllowsVisitingLocation(location) { + applicationAllowsVisitingLocation(location: URL) { const event = this.notifyApplicationBeforeVisitingLocation(location) return !event.defaultPrevented } - notifyApplicationAfterClickingLinkToLocation(link, location, event) { - return dispatch("turbo:click", { + notifyApplicationAfterClickingLinkToLocation(link: Element, location: URL, event: MouseEvent) { + return dispatch("turbo:click", { target: link, detail: { url: location.href, originalEvent: event }, - cancelable: true + cancelable: true, }) } - notifyApplicationBeforeVisitingLocation(location) { - return dispatch("turbo:before-visit", { + notifyApplicationBeforeVisitingLocation(location: URL) { + return dispatch("turbo:before-visit", { detail: { url: location.href }, - cancelable: true + cancelable: true, }) } - notifyApplicationAfterVisitingLocation(location, action) { - return dispatch("turbo:visit", { detail: { url: location.href, action } }) + notifyApplicationAfterVisitingLocation(location: URL, action: Action) { + return dispatch("turbo:visit", { detail: { url: location.href, action } }) } notifyApplicationBeforeCachingSnapshot() { - return dispatch("turbo:before-cache") + return dispatch("turbo:before-cache") } - notifyApplicationBeforeRender(newBody, isPreview, options) { - return dispatch("turbo:before-render", { + notifyApplicationBeforeRender(newBody: HTMLBodyElement, isPreview: boolean, options: PageViewRenderOptions) { + return dispatch("turbo:before-render", { detail: { newBody, isPreview, ...options }, - cancelable: true + cancelable: true, }) } - notifyApplicationAfterRender(isPreview) { - return dispatch("turbo:render", { detail: { isPreview } }) + notifyApplicationAfterRender(isPreview: boolean) { + return dispatch("turbo:render", { detail: { isPreview } }) } - notifyApplicationAfterPageLoad(timing = {}) { - return dispatch("turbo:load", { - detail: { url: this.location.href, timing } + notifyApplicationAfterPageLoad(timing: TimingData = {}) { + return dispatch("turbo:load", { + detail: { url: this.location.href, timing }, }) } - notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { + notifyApplicationAfterVisitingSamePageLocation(oldURL: URL, newURL: URL) { dispatchEvent( new HashChangeEvent("hashchange", { oldURL: oldURL.toString(), - newURL: newURL.toString() + newURL: newURL.toString(), }) ) } - notifyApplicationAfterFrameLoad(frame) { - return dispatch("turbo:frame-load", { target: frame }) + notifyApplicationAfterFrameLoad(frame: FrameElement) { + return dispatch("turbo:frame-load", { target: frame }) } - notifyApplicationAfterFrameRender(fetchResponse, frame) { - return dispatch("turbo:frame-render", { + notifyApplicationAfterFrameRender(fetchResponse: FetchResponse, frame: FrameElement) { + return dispatch("turbo:frame-render", { detail: { fetchResponse }, target: frame, - cancelable: true + cancelable: true, }) } // Helpers - submissionIsNavigatable(form, submitter) { + submissionIsNavigatable(form: HTMLFormElement, submitter?: HTMLElement): boolean { if (this.formMode == "off") { return false } else { @@ -375,7 +404,7 @@ export class Session { } } - elementIsNavigatable(element) { + elementIsNavigatable(element: Element): boolean { const container = findClosestRecursively(element, "[data-turbo]") const withinFrame = findClosestRecursively(element, "turbo-frame") @@ -399,7 +428,7 @@ export class Session { // Private - getActionForLink(link) { + getActionForLink(link: Element): Action { return getVisitAction(link) || "advance" } @@ -419,7 +448,7 @@ export class Session { // older adapters which do not expect URL objects. We should // consider removing this support at some point in the future. -function extendURLWithDeprecatedProperties(url) { +function extendURLWithDeprecatedProperties(url: URL) { Object.defineProperties(url, deprecatedLocationPropertyDescriptors) } @@ -427,6 +456,6 @@ const deprecatedLocationPropertyDescriptors = { absoluteURL: { get() { return this.toString() - } - } + }, + }, } diff --git a/src/core/snapshot.js b/src/core/snapshot.ts similarity index 70% rename from src/core/snapshot.js rename to src/core/snapshot.ts index 2a5c1dbd6..7d5431117 100644 --- a/src/core/snapshot.js +++ b/src/core/snapshot.ts @@ -1,5 +1,7 @@ -export class Snapshot { - constructor(element) { +export class Snapshot { + readonly element: E + + constructor(element: E) { this.element = element } @@ -11,11 +13,11 @@ export class Snapshot { return [...this.element.children] } - hasAnchor(anchor) { + hasAnchor(anchor: string | undefined) { return this.getElementForAnchor(anchor) != null } - getElementForAnchor(anchor) { + getElementForAnchor(anchor: string | undefined) { return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null } @@ -38,12 +40,12 @@ export class Snapshot { return queryPermanentElementsAll(this.element) } - getPermanentElementById(id) { + getPermanentElementById(id: string) { return getPermanentElementById(this.element, id) } - getPermanentElementMapForSnapshot(snapshot) { - const permanentElementMap = {} + getPermanentElementMapForSnapshot(snapshot: Snapshot) { + const permanentElementMap: PermanentElementMap = {} for (const currentPermanentElement of this.permanentElements) { const { id } = currentPermanentElement @@ -57,10 +59,12 @@ export class Snapshot { } } -export function getPermanentElementById(node, id) { +export function getPermanentElementById(node: ParentNode, id: string) { return node.querySelector(`#${id}[data-turbo-permanent]`) } -export function queryPermanentElementsAll(node) { +export function queryPermanentElementsAll(node: ParentNode) { return node.querySelectorAll("[id][data-turbo-permanent]") } + +export type PermanentElementMap = Record diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.ts similarity index 76% rename from src/core/streams/stream_actions.js rename to src/core/streams/stream_actions.ts index 7b06f5b84..e4a619f7c 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.ts @@ -1,4 +1,9 @@ -export const StreamActions = { +import { StreamElement } from "../../elements/stream_element" + +export type TurboStreamAction = (this: StreamElement) => void +export type TurboStreamActions = { [action: string]: TurboStreamAction } + +export const StreamActions: TurboStreamActions = { after() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) }, @@ -30,5 +35,5 @@ export const StreamActions = { targetElement.innerHTML = "" targetElement.append(this.templateContent) }) - } + }, } diff --git a/src/core/streams/stream_message.js b/src/core/streams/stream_message.ts similarity index 59% rename from src/core/streams/stream_message.js rename to src/core/streams/stream_message.ts index 7a3a65b3c..e6ca389a4 100644 --- a/src/core/streams/stream_message.js +++ b/src/core/streams/stream_message.ts @@ -1,9 +1,11 @@ +import { StreamElement } from "../../elements/stream_element" import { activateScriptElement, createDocumentFragment } from "../../util" export class StreamMessage { - static contentType = "text/vnd.turbo-stream.html" + static readonly contentType = "text/vnd.turbo-stream.html" + readonly fragment: DocumentFragment - static wrap(message) { + static wrap(message: StreamMessage | string) { if (typeof message == "string") { return new this(createDocumentFragment(message)) } else { @@ -11,13 +13,13 @@ export class StreamMessage { } } - constructor(fragment) { + constructor(fragment: DocumentFragment) { this.fragment = importStreamElements(fragment) } } -function importStreamElements(fragment) { - for (const element of fragment.querySelectorAll("turbo-stream")) { +function importStreamElements(fragment: DocumentFragment): DocumentFragment { + 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.js b/src/core/streams/stream_message_renderer.ts similarity index 51% rename from src/core/streams/stream_message_renderer.js rename to src/core/streams/stream_message_renderer.ts index c57a695b0..549e4de8d 100644 --- a/src/core/streams/stream_message_renderer.js +++ b/src/core/streams/stream_message_renderer.ts @@ -1,29 +1,29 @@ -import { Bardo } from "../bardo" -import { getPermanentElementById, queryPermanentElementsAll } from "../snapshot" +import { StreamMessage } from "./stream_message" +import { StreamElement } from "../../elements/stream_element" +import { Bardo, BardoDelegate } from "../bardo" +import { PermanentElementMap, getPermanentElementById, queryPermanentElementsAll } from "../snapshot" -export class StreamMessageRenderer { - render({ fragment }) { +export class StreamMessageRenderer implements BardoDelegate { + render({ fragment }: StreamMessage) { Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => document.documentElement.appendChild(fragment) ) } - // Bardo delegate - - enteringBardo(currentPermanentElement, newPermanentElement) { + enteringBardo(currentPermanentElement: Element, newPermanentElement: Element) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)) } leavingBardo() {} } -function getPermanentElementMapForFragment(fragment) { +function getPermanentElementMapForFragment(fragment: DocumentFragment): PermanentElementMap { const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement) - const permanentElementMap = {} + const permanentElementMap: 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.ts b/src/core/types.ts new file mode 100644 index 000000000..90d1817ea --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,16 @@ +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.ts similarity index 61% rename from src/core/url.js rename to src/core/url.ts index dcd50cf26..0e45d8f2b 100644 --- a/src/core/url.js +++ b/src/core/url.ts @@ -1,8 +1,10 @@ -export function expandURL(locatable) { +export type Locatable = URL | string + +export function expandURL(locatable: Locatable) { return new URL(locatable.toString(), document.baseURI) } -export function getAnchor(url) { +export function getAnchor(url: URL) { let anchorMatch if (url.hash) { return url.hash.slice(1) @@ -12,54 +14,54 @@ export function getAnchor(url) { } } -export function getAction(form, submitter) { +export function getAction(form: HTMLFormElement, submitter?: HTMLElement) { const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action return expandURL(action) } -export function getExtension(url) { +export function getExtension(url: URL) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } -export function isHTML(url) { +export function isHTML(url: URL) { return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) } -export function isPrefixedBy(baseURL, url) { +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, rootLocation) { +export function locationIsVisitable(location: URL, rootLocation: URL) { return isPrefixedBy(location, rootLocation) && isHTML(location) } -export function getRequestURL(url) { +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) { +export function toCacheKey(url: URL) { return getRequestURL(url) } -export function urlsAreEqual(left, right) { +export function urlsAreEqual(left: string, right: string) { return expandURL(left).href == expandURL(right).href } -function getPathComponents(url) { +function getPathComponents(url: URL) { return url.pathname.split("/").slice(1) } -function getLastPathComponent(url) { +function getLastPathComponent(url: URL) { return getPathComponents(url).slice(-1)[0] } -function getPrefix(url) { +function getPrefix(url: URL) { return addTrailingSlash(url.origin + url.pathname) } -function addTrailingSlash(value) { +function addTrailingSlash(value: string) { return value.endsWith("/") ? value : value + "/" } diff --git a/src/core/view.js b/src/core/view.ts similarity index 51% rename from src/core/view.js rename to src/core/view.ts index ca81e8bdb..20109a35d 100644 --- a/src/core/view.js +++ b/src/core/view.ts @@ -1,17 +1,43 @@ +import { ReloadReason } from "./native/browser_adapter" +import { Renderer, Render } from "./renderer" +import { Snapshot } from "./snapshot" +import { Position } from "./types" import { getAnchor } from "./url" -export class View { - #resolveRenderPromise = (_value) => {} - #resolveInterceptionPromise = (_value) => {} +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 +} - constructor(delegate, element) { +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) { this.delegate = delegate this.element = element } // Scrolling - scrollToAnchor(anchor) { + scrollToAnchor(anchor: string | undefined) { const element = this.snapshot.getElementForAnchor(anchor) if (element) { this.scrollToElement(element) @@ -21,15 +47,15 @@ export class View { } } - scrollToAnchorFromLocation(location) { + scrollToAnchorFromLocation(location: URL) { this.scrollToAnchor(getAnchor(location)) } - scrollToElement(element) { + scrollToElement(element: Element) { element.scrollIntoView() } - focusElement(element) { + focusElement(element: Element) { if (element instanceof HTMLElement) { if (element.hasAttribute("tabindex")) { element.focus() @@ -41,7 +67,7 @@ export class View { } } - scrollToPosition({ x, y }) { + scrollToPosition({ x, y }: Position) { this.scrollRoot.scrollTo(x, y) } @@ -49,22 +75,22 @@ export class View { this.scrollToPosition({ x: 0, y: 0 }) } - get scrollRoot() { + get scrollRoot(): { scrollTo(x: number, y: number): void } { return window } // Rendering - async render(renderer) { + async render(renderer: R) { const { isPreview, shouldRender, newSnapshot: snapshot } = renderer if (shouldRender) { try { - this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve)) + this.renderPromise = new Promise((resolve) => (this.resolveRenderPromise = resolve)) this.renderer = renderer await this.prepareToRenderSnapshot(renderer) - const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve)) - const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement } + const renderInterception = new Promise((resolve) => (this.resolveInterceptionPromise = resolve)) + const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement } const immediateRender = this.delegate.allowsImmediateRender(snapshot, isPreview, options) if (!immediateRender) await renderInterception @@ -74,7 +100,7 @@ export class View { this.finishRenderingSnapshot(renderer) } finally { delete this.renderer - this.#resolveRenderPromise(undefined) + this.resolveRenderPromise(undefined) delete this.renderPromise } } else { @@ -82,16 +108,16 @@ export class View { } } - invalidate(reason) { + invalidate(reason: ReloadReason) { this.delegate.viewInvalidated(reason) } - async prepareToRenderSnapshot(renderer) { + async prepareToRenderSnapshot(renderer: R) { this.markAsPreview(renderer.isPreview) await renderer.prepareToRender() } - markAsPreview(isPreview) { + markAsPreview(isPreview: boolean) { if (isPreview) { this.element.setAttribute("data-turbo-preview", "") } else { @@ -99,11 +125,11 @@ export class View { } } - async renderSnapshot(renderer) { + async renderSnapshot(renderer: R) { await renderer.render() } - finishRenderingSnapshot(renderer) { + finishRenderingSnapshot(renderer: R) { renderer.finishRendering() } } diff --git a/src/elements/frame_element.js b/src/elements/frame_element.ts similarity index 67% rename from src/elements/frame_element.js rename to src/elements/frame_element.ts index 0e2bee917..8a177a6d7 100644 --- a/src/elements/frame_element.js +++ b/src/elements/frame_element.ts @@ -1,6 +1,28 @@ -export const FrameLoadingStyle = { - eager: "eager", - lazy: "lazy" +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 } /** @@ -20,11 +42,12 @@ export const FrameLoadingStyle = { * */ export class FrameElement extends HTMLElement { - static delegateConstructor = undefined + static delegateConstructor: new (element: FrameElement) => FrameElementDelegate - loaded = Promise.resolve() + loaded: Promise = Promise.resolve() + readonly delegate: FrameElementDelegate - static get observedAttributes() { + static get observedAttributes(): FrameElementObservedAttribute[] { return ["disabled", "complete", "loading", "src"] } @@ -41,11 +64,11 @@ export class FrameElement extends HTMLElement { this.delegate.disconnect() } - reload() { + reload(): Promise { return this.delegate.sourceURLReloaded() } - attributeChangedCallback(name) { + attributeChangedCallback(name: string) { if (name == "loading") { this.delegate.loadingStyleChanged() } else if (name == "complete") { @@ -67,7 +90,7 @@ export class FrameElement extends HTMLElement { /** * Sets the URL to lazily load source HTML from */ - set src(value) { + set src(value: string | null) { if (value) { this.setAttribute("src", value) } else { @@ -78,14 +101,14 @@ export class FrameElement extends HTMLElement { /** * Determines if the element is loading */ - get loading() { + get loading(): FrameLoadingStyle { return frameLoadingStyleFromString(this.getAttribute("loading") || "") } /** * Sets the value of if the element is loading */ - set loading(value) { + set loading(value: FrameLoadingStyle) { if (value) { this.setAttribute("loading", value) } else { @@ -107,7 +130,7 @@ export class FrameElement extends HTMLElement { * * If disabled, no requests will be intercepted by the frame. */ - set disabled(value) { + set disabled(value: boolean) { if (value) { this.setAttribute("disabled", "") } else { @@ -129,7 +152,7 @@ export class FrameElement extends HTMLElement { * * If true, the frame will be scrolled into view automatically on update. */ - set autoscroll(value) { + set autoscroll(value: boolean) { if (value) { this.setAttribute("autoscroll", "") } else { @@ -163,7 +186,7 @@ export class FrameElement extends HTMLElement { } } -function frameLoadingStyleFromString(style) { +function frameLoadingStyleFromString(style: string) { switch (style.toLowerCase()) { case "lazy": return FrameLoadingStyle.lazy diff --git a/src/elements/index.js b/src/elements/index.ts similarity index 100% rename from src/elements/index.js rename to src/elements/index.ts diff --git a/src/elements/stream_element.js b/src/elements/stream_element.ts similarity index 84% rename from src/elements/stream_element.js rename to src/elements/stream_element.ts index 38f463fb3..e0213f97d 100644 --- a/src/elements/stream_element.js +++ b/src/elements/stream_element.ts @@ -1,6 +1,10 @@ import { StreamActions } from "../core/streams/stream_actions" import { nextAnimationFrame } from "../util" +type Render = (currentElement: StreamElement) => Promise + +export type TurboBeforeStreamRenderEvent = CustomEvent<{ newStream: StreamElement; render: Render }> + //