Repository: hotwired/turbo Branch: main Commit: 4481af6b6d74 Files: 223 Total size: 547.0 KB Directory structure: gitextract_vgl1u45s/ ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .eslintignore ├── .eslintrc.js ├── .github/ │ ├── scripts/ │ │ └── publish-dev-build │ └── workflows/ │ ├── ci.yml │ └── dev-builds.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ ├── launch.json │ └── tasks.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── MIT-LICENSE ├── README.md ├── package.json ├── playwright.config.js ├── rollup.config.js ├── src/ │ ├── core/ │ │ ├── bardo.js │ │ ├── cache.js │ │ ├── config/ │ │ │ ├── drive.js │ │ │ ├── forms.js │ │ │ └── index.js │ │ ├── drive/ │ │ │ ├── error_renderer.js │ │ │ ├── form_submission.js │ │ │ ├── head_snapshot.js │ │ │ ├── history.js │ │ │ ├── limited_set.js │ │ │ ├── morphing_page_renderer.js │ │ │ ├── navigator.js │ │ │ ├── page_renderer.js │ │ │ ├── page_snapshot.js │ │ │ ├── page_view.js │ │ │ ├── prefetch_cache.js │ │ │ ├── preloader.js │ │ │ ├── progress_bar.js │ │ │ ├── snapshot_cache.js │ │ │ ├── view_transitioner.js │ │ │ └── visit.js │ │ ├── errors.js │ │ ├── frames/ │ │ │ ├── frame_controller.js │ │ │ ├── frame_redirector.js │ │ │ ├── frame_renderer.js │ │ │ ├── frame_view.js │ │ │ ├── link_interceptor.js │ │ │ └── morphing_frame_renderer.js │ │ ├── index.js │ │ ├── lru_cache.js │ │ ├── morphing.js │ │ ├── native/ │ │ │ └── browser_adapter.js │ │ ├── renderer.js │ │ ├── session.js │ │ ├── snapshot.js │ │ ├── streams/ │ │ │ ├── stream_actions.js │ │ │ ├── stream_message.js │ │ │ └── stream_message_renderer.js │ │ ├── url.js │ │ └── view.js │ ├── elements/ │ │ ├── frame_element.js │ │ ├── index.js │ │ ├── stream_element.js │ │ └── stream_source_element.js │ ├── http/ │ │ ├── fetch.js │ │ ├── fetch_request.js │ │ ├── fetch_response.js │ │ └── index.js │ ├── index.js │ ├── observers/ │ │ ├── appearance_observer.js │ │ ├── cache_observer.js │ │ ├── form_link_click_observer.js │ │ ├── form_submit_observer.js │ │ ├── link_click_observer.js │ │ ├── link_prefetch_observer.js │ │ ├── page_observer.js │ │ ├── scroll_observer.js │ │ └── stream_observer.js │ ├── polyfills/ │ │ └── index.js │ ├── script_warning.js │ ├── tests/ │ │ ├── fixtures/ │ │ │ ├── 422.html │ │ │ ├── 422_morph.html │ │ │ ├── 422_tall.html │ │ │ ├── 500.html │ │ │ ├── additional_assets.html │ │ │ ├── additional_script.html │ │ │ ├── async_script.html │ │ │ ├── async_script_2.html │ │ │ ├── autofocus-inert.html │ │ │ ├── autofocus.html │ │ │ ├── bare.html │ │ │ ├── body_noscript.html │ │ │ ├── body_noscript_with_content.html │ │ │ ├── body_script.html │ │ │ ├── cache_observer.html │ │ │ ├── dir_rtl.html │ │ │ ├── drive.html │ │ │ ├── drive_disabled.html │ │ │ ├── es_locale.html │ │ │ ├── esm.html │ │ │ ├── eval_false_script.html │ │ │ ├── form.html │ │ │ ├── form_mode.html │ │ │ ├── frame_navigation.html │ │ │ ├── frame_preloading.html │ │ │ ├── frame_refresh_after_navigation.html │ │ │ ├── frame_refresh_morph.html │ │ │ ├── frame_refresh_reload.html │ │ │ ├── frames/ │ │ │ │ ├── body_script.html │ │ │ │ ├── body_script_2.html │ │ │ │ ├── empty_head.html │ │ │ │ ├── eval_false_script.html │ │ │ │ ├── form-redirect.html │ │ │ │ ├── form-redirected.html │ │ │ │ ├── form.html │ │ │ │ ├── frame.html │ │ │ │ ├── frame_for_eager.html │ │ │ │ ├── hello.html │ │ │ │ ├── parent.html │ │ │ │ ├── part.html │ │ │ │ ├── preloading.html │ │ │ │ ├── recursive.html │ │ │ │ ├── self.html │ │ │ │ └── unvisitable.html │ │ │ ├── frames.html │ │ │ ├── greetings.ejs │ │ │ ├── head_script.html │ │ │ ├── headers.html │ │ │ ├── hot_preloading.html │ │ │ ├── hover_to_prefetch.html │ │ │ ├── hover_to_prefetch_custom_cache_time.html │ │ │ ├── hover_to_prefetch_disabled.html │ │ │ ├── hover_to_prefetch_iframe.html │ │ │ ├── hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html │ │ │ ├── link_redirect.html │ │ │ ├── link_redirect_target.html │ │ │ ├── loading.html │ │ │ ├── navigation.html │ │ │ ├── noscript.css │ │ │ ├── one.html │ │ │ ├── page_refresh.html │ │ │ ├── page_refresh_replace.html │ │ │ ├── page_refresh_scroll_reset.html │ │ │ ├── page_refresh_stream_action.html │ │ │ ├── page_refreshed.html │ │ │ ├── page_with_eager_frame.html │ │ │ ├── pausable_rendering.html │ │ │ ├── pausable_requests.html │ │ │ ├── permanent_children.html │ │ │ ├── permanent_element.html │ │ │ ├── prefetched.html │ │ │ ├── preloaded.html │ │ │ ├── preloading.html │ │ │ ├── remote_permanent_frame.html │ │ │ ├── rendering.html │ │ │ ├── response.js │ │ │ ├── root/ │ │ │ │ ├── index.html │ │ │ │ └── page.html │ │ │ ├── scroll/ │ │ │ │ ├── one.html │ │ │ │ └── two.html │ │ │ ├── scroll_restoration.html │ │ │ ├── stream.html │ │ │ ├── stylesheets/ │ │ │ │ ├── common.css │ │ │ │ ├── left.css │ │ │ │ ├── left.html │ │ │ │ ├── right.css │ │ │ │ └── right.html │ │ │ ├── tabs/ │ │ │ │ ├── three.html │ │ │ │ └── two.html │ │ │ ├── tabs.html │ │ │ ├── target.html │ │ │ ├── test.css │ │ │ ├── test.js │ │ │ ├── tracked_asset_change.html │ │ │ ├── tracked_nonce_change.html │ │ │ ├── transitions/ │ │ │ │ ├── left.html │ │ │ │ ├── left_legacy.html │ │ │ │ ├── other.html │ │ │ │ ├── right.html │ │ │ │ └── right_legacy.html │ │ │ ├── two.html │ │ │ ├── ujs.html │ │ │ ├── umd.html │ │ │ ├── video.webm │ │ │ ├── visit.html │ │ │ └── visit_control_reload.html │ │ ├── functional/ │ │ │ ├── async_script_tests.js │ │ │ ├── autofocus_tests.js │ │ │ ├── cache_observer_tests.js │ │ │ ├── drive_disabled_tests.js │ │ │ ├── drive_stylesheet_merging_tests.js │ │ │ ├── drive_tests.js │ │ │ ├── drive_view_transition_legacy_tests.js │ │ │ ├── drive_view_transition_tests.js │ │ │ ├── form_mode_tests.js │ │ │ ├── form_submission_tests.js │ │ │ ├── frame_navigation_tests.js │ │ │ ├── frame_tests.js │ │ │ ├── import_tests.js │ │ │ ├── link_prefetch_observer_tests.js │ │ │ ├── loading_tests.js │ │ │ ├── navigation_tests.js │ │ │ ├── page_refresh_stream_action_tests.js │ │ │ ├── page_refresh_tests.js │ │ │ ├── pausable_rendering_tests.js │ │ │ ├── pausable_requests_tests.js │ │ │ ├── preloader_tests.js │ │ │ ├── rendering_tests.js │ │ │ ├── root_tests.js │ │ │ ├── scroll_restoration_tests.js │ │ │ ├── stream_tests.js │ │ │ └── visit_tests.js │ │ ├── helpers/ │ │ │ ├── dom_test_case.js │ │ │ └── page.js │ │ ├── integration/ │ │ │ └── ujs_tests.js │ │ ├── server.mjs │ │ └── unit/ │ │ ├── deprecated_adapter_support_tests.js │ │ ├── export_tests.js │ │ ├── limited_set_tests.js │ │ ├── native_adapter_support_tests.js │ │ └── stream_element_tests.js │ └── util.js └── web-test-runner.config.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ # See comments in devcontainer.json for details of setting the playwright tag. ARG PLAYWRIGHT_TAG="v1.51.1-jammy" FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_TAG} WORKDIR /app ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/typescript-node { "name": "Node.js & Playwright", "build": { "dockerfile": "Dockerfile", // Change to pick a different tag, see https://mcr.microsoft.com/en-us/product/playwright/tags // // IMPORTANT: The playwright image version must match @playwright/test version from package.json. // Otherwise it will not work, the test version will look for the browser versions which it won't // find on the image. // // pick '...-arm64' version if running on Apple Silicon. "args": { "PLAYWRIGHT_TAG": "v1.51.1-jammy" } }, // Add the IDs of extensions you want installed when the container is created. "customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint"] } }, // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [9000], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "yarn install && yarn build" } ================================================ FILE: .eslintignore ================================================ dist/ node_modules/ ================================================ FILE: .eslintrc.js ================================================ 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 } } ================================================ FILE: .github/scripts/publish-dev-build ================================================ #!/usr/bin/env bash set -eux DEV_BUILD_REPO_NAME="hotwired/dev-builds" DEV_BUILD_ORIGIN_URL="https://${1}@github.com/${DEV_BUILD_REPO_NAME}.git" BUILD_PATH="$HOME/publish-dev-build" mkdir "$BUILD_PATH" cd "$GITHUB_WORKSPACE" package_name="$(jq -r .name package.json)" package_files=( dist package.json ) tag="${package_name}/${GITHUB_SHA:0:7}" name="$(git log -n 1 --format=format:%cn)" email="$(git log -n 1 --format=format:%ce)" subject="$(git log -n 1 --format=format:%s)" date="$(git log -n 1 --format=format:%ai)" url="https://github.com/${GITHUB_REPOSITORY}/tree/${GITHUB_SHA}" message="$tag $subject"$'\n\n'"$url" cp -R "${package_files[@]}" "$BUILD_PATH" cd "$BUILD_PATH" git init . git remote add origin "$DEV_BUILD_ORIGIN_URL" git symbolic-ref HEAD refs/heads/publish-dev-build git add "${package_files[@]}" GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \ GIT_AUTHOR_NAME="$name" GIT_COMMITTER_NAME="$name" \ GIT_AUTHOR_EMAIL="$email" GIT_COMMITTER_EMAIL="$email" \ git commit -m "$message" git tag "$tag" [ "$GITHUB_REF" != "refs/heads/main" ] || git tag -f "${package_name}/latest" git push -f --tags echo done ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] jobs: build: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '22' cache: 'yarn' - run: yarn install --frozen-lockfile - run: yarn run playwright install --with-deps - run: yarn build - name: Set Chrome Version run: | CHROMEVER="$(chromedriver --version | cut -d' ' -f2)" echo "Actions ChromeDriver is $CHROMEVER" echo "CHROMEVER=${CHROMEVER}" >> $GITHUB_ENV - name: Lint run: yarn lint - name: Unit Test run: yarn test:unit - name: Chrome Test run: yarn test:browser --project=chrome - name: Firefox Test run: yarn test:browser --project=firefox - uses: actions/upload-artifact@v4 with: name: turbo-dist path: dist/* ================================================ FILE: .github/workflows/dev-builds.yml ================================================ name: dev-builds on: workflow_dispatch: push: branches: - main - 'builds/**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '22' cache: 'yarn' - run: yarn install --frozen-lockfile - run: yarn build - name: Publish dev build run: .github/scripts/publish-dev-build '${{ secrets.DEV_BUILD_GITHUB_TOKEN }}' ================================================ FILE: .gitignore ================================================ /dist /node_modules /test-results *.log package-lock.json ================================================ FILE: .prettierignore ================================================ dist/ node_modules/ ================================================ FILE: .prettierrc.json ================================================ { "singleQuote": false, "printWidth": 120, "semi": false, "trailingComma" : "none" } ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Turbo: Debug browser tests", "cwd": "${workspaceFolder}", "port": 9229, "outputCapture": "std", "internalConsoleOptions": "openOnSessionStart", "runtimeExecutable": "yarn", "runtimeArgs": ["test"] } ] } ================================================ FILE: .vscode/tasks.json ================================================ { // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "Turbo: Build dist directory", "type": "shell", "command": "yarn build", "group": { "kind": "build", "isDefault": true } }, { "label": "Turbo: Run tests", "type": "shell", "dependsOn": "Turbo: Build dist directory", "command": "yarn test", "group": { "kind": "test", "isDefault": true } }, { "label": "Turbo: Start dev server", "type": "shell", "dependsOn": "Turbo: Build dist directory", "command": "yarn start", "problemMatcher": [] } ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog Please see [our GitHub "Releases" page](https://github.com/hotwired/turbo/releases). ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting one of the project maintainers listed below. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Project Maintainers * Sam Stephenson <> * Javan Makhmali <> ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ [![Version](https://img.shields.io/npm/v/@hotwired/turbo)](https://www.npmjs.com/package/@hotwired/turbo) [![License](https://img.shields.io/github/license/hotwired/turbo)](https://github.com/hotwired/turbo) # Contributing Note that we have a [code of conduct](https://github.com/hotwired/turbo/blob/main/CODE_OF_CONDUCT.md). Please follow it in your interactions with this project. ## Sending a Pull Request The core team is monitoring for pull requests. We will review your pull request and either merge it, request changes to it, or close it with an explanation. Before submitting a pull request, please: 1. Fork the repository and create your branch. 2. Follow the setup instructions in this file. 3. If you’re fixing a bug or adding code that should be tested, add tests! 4. Ensure the test suite passes. ## Developing locally First, clone the `hotwired/turbo` repository and install dependencies: ```bash git clone https://github.com/hotwired/turbo.git ``` ```bash cd turbo yarn install ``` Then create a branch for your changes: ```bash git checkout -b ``` ### Testing Tests are run through `yarn` using [Web Test Runner](https://modern-web.dev/docs/test-runner/overview/) with [Playwright](https://github.com/microsoft/playwright) for browser testing. Browser and runtime configuration can be found in [`web-test-runner.config.mjs`](./web-test-runner.config.mjs) and [`playwright.config.js`](./playwright.config.js). To begin testing, install the browser drivers: ```bash yarn playwright install --with-deps ``` Then build the source. Because tests are run against the compiled source (and are themselves compiled) be sure to run `yarn build` prior to testing. Alternatively, you can run `yarn watch` to build and watch for changes. ```bash yarn build ``` ### Running the test suite The test suite can be run with `yarn`, using the test commands defined in [`package.json`](./package.json). To run all tests in all configured browsers: ```bash yarn test ``` To run just the unit or browser tests: ```bash yarn test:unit yarn test:browser ``` By default, tests are run in "headless" mode against all configured browsers (currently `chrome` and `firefox`). Use the `--headed` flag to run in normal mode. Use the `--project` flag to run against a particular browser. ```bash yarn test:browser --project=firefox yarn test:browser --project=chrome yarn test:browser --project=chrome --headed ``` ### Running a single test To run a single test file, pass its path as an argument. To run a particular test case, append its starting line number after a colon. ```bash yarn test:browser src/tests/functional/drive_tests.js yarn test:browser src/tests/functional/drive_tests.js:11 yarn test:browser src/tests/functional/drive_tests.js:11 --project=chrome ``` ### Running the local web server Because tests are running headless in browsers, debugging can be difficult. Sometimes the simplest thing to do is load the test fixtures into the browser and navigate manually. To make this easier, a local web server is included. To run the web server, ensure the source is built and start the server with `yarn`: ```bash yarn build yarn start ``` The web server is available on port 9000, serving from the project root. Fixture files are accessible by path. For example, the file at `src/tests/fixtures/rendering.html` will be accessible at . ================================================ FILE: MIT-LICENSE ================================================ Copyright (c) 37signals Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Turbo Turbo uses complementary techniques to dramatically reduce the amount of custom JavaScript that most web applications will need to write: * Turbo Drive accelerates links and form submissions by negating the need for full page reloads. * Turbo Frames decompose pages into independent contexts, which scope navigation and can be lazily loaded. * Turbo Streams deliver page changes over WebSocket or in response to form submissions using just HTML and a set of CRUD-like actions. * Turbo Native lets your majestic monolith form the center of your native iOS and Android apps, with seamless transitions between web and native sections. It's all done by sending HTML over the wire. And for those instances when that's not enough, you can reach for the other side of Hotwire, and finish the job with [Stimulus](https://github.com/hotwired/stimulus). Read more on [turbo.hotwired.dev](https://turbo.hotwired.dev). ## Contributing Please read [CONTRIBUTING.md](./CONTRIBUTING.md). © 2026 37signals LLC. ================================================ FILE: package.json ================================================ { "name": "@hotwired/turbo", "version": "8.0.23", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", "files": [ "dist/*.js", "dist/*.js.map" ], "repository": { "type": "git", "url": "git+https://github.com/hotwired/turbo.git" }, "keywords": [ "hotwire", "turbo", "browser", "pushstate" ], "author": "37signals LLC", "contributors": [ "Jeffrey Hardy ", "Javan Makhmali ", "Sam Stephenson " ], "license": "MIT", "bugs": { "url": "https://github.com/hotwired/turbo/issues" }, "homepage": "https://turbo.hotwired.dev", "publishConfig": { "access": "public" }, "devDependencies": { "@open-wc/testing": "^3.1.7", "@playwright/test": "~1.51.1", "@rollup/plugin-node-resolve": "13.1.3", "@web/dev-server-esbuild": "^0.3.3", "@web/test-runner": "^0.15.0", "@web/test-runner-playwright": "^0.9.0", "arg": "^5.0.1", "body-parser": "^1.20.1", "eslint": "^8.13.0", "express": "^4.18.2", "idiomorph": "~0.7.4", "multer": "^2.0.2", "rollup": "^2.35.1" }, "scripts": { "clean": "rm -fr dist", "clean:win": "rmdir /s /q dist", "build": "yarn install && rollup -c", "build:win": "yarn install && rollup -c", "watch": "rollup -wc", "start": "node src/tests/server.mjs", "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 && yarn publish", "lint": "eslint . --ext .js" }, "engines": { "node": ">= 18" } } ================================================ FILE: playwright.config.js ================================================ import { devices } from "@playwright/test" const config = { projects: [ { name: "chrome", use: { ...devices["Desktop Chrome"], contextOptions: { timeout: 10000 }, hasTouch: true } }, { name: "firefox", use: { ...devices["Desktop Firefox"], contextOptions: { timeout: 10000 }, hasTouch: true } } ], timeout: 10000, browserStartTimeout: 10000, retries: 2, testDir: "./src/tests/", testMatch: /(functional|integration)\/.*_tests\.js/, webServer: { command: "yarn start", url: "http://localhost:9000/src/tests/fixtures/test.js", timeout: 10000, // eslint-disable-next-line no-undef reuseExistingServer: !process.env.CI }, use: { baseURL: "http://localhost:9000/" } } export default config ================================================ FILE: rollup.config.js ================================================ import resolve from "@rollup/plugin-node-resolve" import { version } from "./package.json" const year = new Date().getFullYear() const banner = `/*!\nTurbo ${version}\nCopyright © ${year} 37signals LLC\n */` export default [ { input: "src/index.js", output: [ { name: "Turbo", file: "dist/turbo.es2017-umd.js", format: "umd", banner }, { file: "dist/turbo.es2017-esm.js", format: "esm", banner } ], plugins: [resolve()], watch: { include: "src/**" } } ] ================================================ FILE: src/core/bardo.js ================================================ export class Bardo { static async preservingPermanentElements(delegate, permanentElementMap, callback) { const bardo = new this(delegate, permanentElementMap) bardo.enter() await callback() bardo.leave() } constructor(delegate, permanentElementMap) { this.delegate = delegate this.permanentElementMap = permanentElementMap } enter() { for (const id in this.permanentElementMap) { const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id] this.delegate.enteringBardo(currentPermanentElement, newPermanentElement) this.replaceNewPermanentElementWithPlaceholder(newPermanentElement) } } leave() { for (const id in this.permanentElementMap) { const [currentPermanentElement] = this.permanentElementMap[id] this.replaceCurrentPermanentElementWithClone(currentPermanentElement) this.replacePlaceholderWithPermanentElement(currentPermanentElement) this.delegate.leavingBardo(currentPermanentElement) } } replaceNewPermanentElementWithPlaceholder(permanentElement) { const placeholder = createPlaceholderForPermanentElement(permanentElement) permanentElement.replaceWith(placeholder) } replaceCurrentPermanentElementWithClone(permanentElement) { const clone = permanentElement.cloneNode(true) permanentElement.replaceWith(clone) } replacePlaceholderWithPermanentElement(permanentElement) { const placeholder = this.getPlaceholderById(permanentElement.id) placeholder?.replaceWith(permanentElement) } getPlaceholderById(id) { return this.placeholders.find((element) => element.content == id) } get placeholders() { return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] } } function createPlaceholderForPermanentElement(permanentElement) { const element = document.createElement("meta") element.setAttribute("name", "turbo-permanent-placeholder") element.setAttribute("content", permanentElement.id) return element } ================================================ FILE: src/core/cache.js ================================================ import { setMetaContent } from "../util" export class Cache { constructor(session) { this.session = session } clear() { this.session.clearCache() } resetCacheControl() { this.#setCacheControl("") } exemptPageFromCache() { this.#setCacheControl("no-cache") } exemptPageFromPreview() { this.#setCacheControl("no-preview") } #setCacheControl(value) { setMetaContent("turbo-cache-control", value) } } ================================================ FILE: src/core/config/drive.js ================================================ export const drive = { enabled: true, progressBarDelay: 500, unvisitableExtensions: new Set( [ ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc", ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg", ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi", ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf", ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv", ".xls", ".xlsx", ".xml", ".zip" ] ) } ================================================ FILE: src/core/config/forms.js ================================================ import { cancelEvent } from "../../util" const submitter = { "aria-disabled": { beforeSubmit: submitter => { submitter.setAttribute("aria-disabled", "true") submitter.addEventListener("click", cancelEvent) }, afterSubmit: submitter => { submitter.removeAttribute("aria-disabled") submitter.removeEventListener("click", cancelEvent) } }, "disabled": { beforeSubmit: submitter => submitter.disabled = true, afterSubmit: submitter => submitter.disabled = false } } class Config { #submitter = null constructor(config) { Object.assign(this, config) } get submitter() { return this.#submitter } set submitter(value) { this.#submitter = submitter[value] || value } } export const forms = new Config({ mode: "on", submitter: "disabled" }) ================================================ FILE: src/core/config/index.js ================================================ import { drive } from "./drive" import { forms } from "./forms" export const config = { drive, forms } ================================================ FILE: src/core/drive/error_renderer.js ================================================ import { activateScriptElement } from "../../util" import { Renderer } from "../renderer" export class ErrorRenderer extends Renderer { static renderElement(currentElement, newElement) { const { documentElement, body } = document documentElement.replaceChild(newElement, body) } async render() { this.replaceHeadAndBody() this.activateScriptElements() } replaceHeadAndBody() { const { documentElement, head } = document documentElement.replaceChild(this.newHead, head) this.renderElement(this.currentElement, this.newElement) } activateScriptElements() { for (const replaceableElement of this.scriptElements) { const parentNode = replaceableElement.parentNode if (parentNode) { const element = activateScriptElement(replaceableElement) parentNode.replaceChild(element, replaceableElement) } } } get newHead() { return this.newSnapshot.headSnapshot.element } get scriptElements() { return document.documentElement.querySelectorAll("script") } } ================================================ FILE: src/core/drive/form_submission.js ================================================ import { FetchRequest, FetchMethod, fetchMethodFromString, fetchEnctypeFromString, isSafe } from "../../http/fetch_request" import { expandURL } from "../url" import { clearBusyState, dispatch, getAttribute, getMetaContent, hasAttribute, markAsBusy } from "../../util" import { StreamMessage } from "../streams/stream_message" import { prefetchCache } from "./prefetch_cache" import { config } from "../config" export const FormSubmissionState = { initialized: "initialized", requesting: "requesting", waiting: "waiting", receiving: "receiving", stopping: "stopping", stopped: "stopped" } export const FormEnctype = { urlEncoded: "application/x-www-form-urlencoded", multipart: "multipart/form-data", plain: "text/plain" } export class FormSubmission { state = FormSubmissionState.initialized static confirmMethod(message) { return Promise.resolve(confirm(message)) } constructor(delegate, formElement, submitter, mustRedirect = false) { const method = getMethod(formElement, submitter) const action = getAction(getFormAction(formElement, submitter), method) const body = buildFormData(formElement, submitter) const enctype = getEnctype(formElement, submitter) this.delegate = delegate this.formElement = formElement this.submitter = submitter this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype) this.mustRedirect = mustRedirect } get method() { return this.fetchRequest.method } set method(value) { this.fetchRequest.method = value } get action() { return this.fetchRequest.url.toString() } set action(value) { this.fetchRequest.url = expandURL(value) } get body() { return this.fetchRequest.body } get enctype() { return this.fetchRequest.enctype } get isSafe() { return this.fetchRequest.isSafe } get location() { return this.fetchRequest.url } // The submission process async start() { const { initialized, requesting } = FormSubmissionState const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement) if (typeof confirmationMessage === "string") { const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : FormSubmission.confirmMethod const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter) if (!answer) { return } } if (this.state == initialized) { this.state = requesting return this.fetchRequest.perform() } } stop() { const { stopping, stopped } = FormSubmissionState if (this.state != stopping && this.state != stopped) { this.state = stopping this.fetchRequest.cancel() return true } } // Fetch request delegate prepareRequest(request) { if (!request.isSafe) { const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token") if (token) { request.headers["X-CSRF-Token"] = token } } if (this.requestAcceptsTurboStreamResponse(request)) { request.acceptResponseType(StreamMessage.contentType) } } requestStarted(_request) { this.state = FormSubmissionState.waiting if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter) this.setSubmitsWith() markAsBusy(this.formElement) dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } }) this.delegate.formSubmissionStarted(this) } requestPreventedHandlingResponse(request, response) { prefetchCache.clear() this.result = { success: response.succeeded, fetchResponse: response } } requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response) return } prefetchCache.clear() if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error = new Error("Form responses must redirect to another location") this.delegate.formSubmissionErrored(this, error) } else { this.state = FormSubmissionState.receiving this.result = { success: true, fetchResponse: response } this.delegate.formSubmissionSucceededWithResponse(this, response) } } requestFailedWithResponse(request, response) { this.result = { success: false, fetchResponse: response } this.delegate.formSubmissionFailedWithResponse(this, response) } requestErrored(request, error) { this.result = { success: false, error } this.delegate.formSubmissionErrored(this, error) } requestFinished(_request) { this.state = FormSubmissionState.stopped if (this.submitter) config.forms.submitter.afterSubmit(this.submitter) this.resetSubmitterText() clearBusyState(this.formElement) dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result } }) this.delegate.formSubmissionFinished(this) } // Private setSubmitsWith() { if (!this.submitter || !this.submitsWith) return if (this.submitter.matches("button")) { this.originalSubmitText = this.submitter.innerHTML this.submitter.innerHTML = this.submitsWith } else if (this.submitter.matches("input")) { const input = this.submitter this.originalSubmitText = input.value input.value = this.submitsWith } } resetSubmitterText() { if (!this.submitter || !this.originalSubmitText) return if (this.submitter.matches("button")) { this.submitter.innerHTML = this.originalSubmitText } else if (this.submitter.matches("input")) { const input = this.submitter input.value = this.originalSubmitText } } requestMustRedirect(request) { return !request.isSafe && this.mustRedirect } requestAcceptsTurboStreamResponse(request) { return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) } get submitsWith() { return this.submitter?.getAttribute("data-turbo-submits-with") } } function buildFormData(formElement, submitter) { const formData = new FormData(formElement) const name = submitter?.getAttribute("name") const value = submitter?.getAttribute("value") if (name) { formData.append(name, value || "") } return formData } function getCookieValue(cookieName) { if (cookieName != null) { const cookies = document.cookie ? document.cookie.split("; ") : [] const cookie = cookies.find((cookie) => cookie.startsWith(cookieName)) if (cookie) { const value = cookie.split("=").slice(1).join("=") return value ? decodeURIComponent(value) : undefined } } } function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected } function getFormAction(formElement, submitter) { const formElementAction = typeof formElement.action === "string" ? formElement.action : null if (submitter?.hasAttribute("formaction")) { return submitter.getAttribute("formaction") || "" } else { return formElement.getAttribute("action") || formElementAction || "" } } function getAction(formAction, fetchMethod) { const action = expandURL(formAction) if (isSafe(fetchMethod)) { action.search = "" } return action } function getMethod(formElement, submitter) { const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "" return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get } function getEnctype(formElement, submitter) { return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype) } ================================================ FILE: src/core/drive/head_snapshot.js ================================================ import { elementIsStylesheet } from "../../util" import { Snapshot } from "../snapshot" export class HeadSnapshot extends Snapshot { detailsByOuterHTML = this.children .filter((element) => !elementIsNoscript(element)) .map((element) => elementWithoutNonce(element)) .reduce((result, element) => { const { outerHTML } = element const details = outerHTML in result ? result[outerHTML] : { type: elementType(element), tracked: elementIsTracked(element), elements: [] } return { ...result, [outerHTML]: { ...details, elements: [...details.elements, element] } } }, {}) get trackedElementSignature() { return Object.keys(this.detailsByOuterHTML) .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) .join("") } getScriptElementsNotInSnapshot(snapshot) { return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) } getStylesheetElementsNotInSnapshot(snapshot) { return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) } getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) { return Object.keys(this.detailsByOuterHTML) .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)) .map((outerHTML) => this.detailsByOuterHTML[outerHTML]) .filter(({ type }) => type == matchedType) .map(({ elements: [element] }) => element) } get provisionalElements() { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML] if (type == null && !tracked) { return [...result, ...elements] } else if (elements.length > 1) { return [...result, ...elements.slice(1)] } else { return result } }, []) } getMetaValue(name) { const element = this.findMetaElementByName(name) return element ? element.getAttribute("content") : null } findMetaElementByName(name) { return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { const { elements: [element] } = this.detailsByOuterHTML[outerHTML] return elementIsMetaElementWithName(element, name) ? element : result }, undefined | undefined) } } function elementType(element) { if (elementIsScript(element)) { return "script" } else if (elementIsStylesheet(element)) { return "stylesheet" } } function elementIsTracked(element) { return element.getAttribute("data-turbo-track") == "reload" } function elementIsScript(element) { const tagName = element.localName return tagName == "script" } function elementIsNoscript(element) { const tagName = element.localName return tagName == "noscript" } function elementIsMetaElementWithName(element, name) { const tagName = element.localName return tagName == "meta" && element.getAttribute("name") == name } function elementWithoutNonce(element) { if (element.hasAttribute("nonce")) { element.setAttribute("nonce", "") } return element } ================================================ FILE: src/core/drive/history.js ================================================ import { uuid } from "../../util" export class History { location restorationIdentifier = uuid() restorationData = {} started = false currentIndex = 0 constructor(delegate) { this.delegate = delegate } start() { if (!this.started) { addEventListener("popstate", this.onPopState, false) this.currentIndex = history.state?.turbo?.restorationIndex || 0 this.started = true this.replace(new URL(window.location.href)) } } stop() { if (this.started) { removeEventListener("popstate", this.onPopState, false) this.started = false } } push(location, restorationIdentifier) { this.update(history.pushState, location, restorationIdentifier) } replace(location, restorationIdentifier) { this.update(history.replaceState, location, restorationIdentifier) } update(method, location, restorationIdentifier = uuid()) { if (method === history.pushState) ++this.currentIndex const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } } method.call(history, state, "", location.href) this.location = location this.restorationIdentifier = restorationIdentifier } // Restoration data getRestorationDataForIdentifier(restorationIdentifier) { return this.restorationData[restorationIdentifier] || {} } updateRestorationData(additionalData) { const { restorationIdentifier } = this const restorationData = this.restorationData[restorationIdentifier] this.restorationData[restorationIdentifier] = { ...restorationData, ...additionalData } } // Scroll restoration assumeControlOfScrollRestoration() { if (!this.previousScrollRestoration) { this.previousScrollRestoration = history.scrollRestoration ?? "auto" history.scrollRestoration = "manual" } } relinquishControlOfScrollRestoration() { if (this.previousScrollRestoration) { history.scrollRestoration = this.previousScrollRestoration delete this.previousScrollRestoration } } // Event handlers onPopState = (event) => { const { turbo } = event.state || {} this.location = new URL(window.location.href) if (turbo) { const { restorationIdentifier, restorationIndex } = turbo this.restorationIdentifier = restorationIdentifier const direction = restorationIndex > this.currentIndex ? "forward" : "back" this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction) this.currentIndex = restorationIndex } else { this.currentIndex++ this.delegate.historyPoppedWithEmptyState(this.location) } } } ================================================ FILE: src/core/drive/limited_set.js ================================================ export class LimitedSet extends Set { constructor(maxSize) { super() this.maxSize = maxSize } add(value) { if (this.size >= this.maxSize) { const iterator = this.values() const oldestValue = iterator.next().value this.delete(oldestValue) } super.add(value) } } ================================================ FILE: src/core/drive/morphing_page_renderer.js ================================================ import { PageRenderer } from "./page_renderer" import { dispatch } from "../../util" import { morphElements, shouldRefreshFrameWithMorphing, closestFrameReloadableWithMorphing } from "../morphing" export class MorphingPageRenderer extends PageRenderer { static renderElement(currentElement, newElement) { morphElements(currentElement, newElement, { callbacks: { beforeNodeMorphed: (node, newNode) => { if ( shouldRefreshFrameWithMorphing(node, newNode) && !closestFrameReloadableWithMorphing(node) ) { node.reload() return false } return true } } }) dispatch("turbo:morph", { detail: { currentElement, newElement } }) } async preservingPermanentElements(callback) { return await callback() } get renderMethod() { return "morph" } get shouldAutofocus() { return false } } ================================================ FILE: src/core/drive/navigator.js ================================================ import { getVisitAction } from "../../util" import { FormSubmission } from "./form_submission" import { expandURL } from "../url" import { Visit } from "./visit" import { PageSnapshot } from "./page_snapshot" export class Navigator { constructor(delegate) { this.delegate = delegate } proposeVisit(location, options = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { this.delegate.visitProposedToLocation(location, options) } } startVisit(locatable, restorationIdentifier, options = {}) { this.stop() this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { referrer: this.location, ...options }) this.currentVisit.start() } submitForm(form, submitter) { this.stop() this.formSubmission = new FormSubmission(this, form, submitter, true) this.formSubmission.start() } stop() { if (this.formSubmission) { this.formSubmission.stop() delete this.formSubmission } if (this.currentVisit) { this.currentVisit.cancel() delete this.currentVisit } } get adapter() { return this.delegate.adapter } get view() { return this.delegate.view } get rootLocation() { return this.view.snapshot.rootLocation } get history() { return this.delegate.history } // Form submission delegate formSubmissionStarted(formSubmission) { // Not all adapters implement formSubmissionStarted if (typeof this.adapter.formSubmissionStarted === "function") { this.adapter.formSubmissionStarted(formSubmission) } } async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { if (formSubmission == this.formSubmission) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { const shouldCacheSnapshot = formSubmission.isSafe if (!shouldCacheSnapshot) { this.view.clearSnapshotCache() } const { statusCode, redirected } = fetchResponse const action = this.#getActionForFormSubmission(formSubmission, fetchResponse) const visitOptions = { action, shouldCacheSnapshot, response: { statusCode, responseHTML, redirected } } this.proposeVisit(fetchResponse.location, visitOptions) } } } async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { const responseHTML = await fetchResponse.responseHTML if (responseHTML) { const snapshot = PageSnapshot.fromHTMLString(responseHTML) if (fetchResponse.serverError) { await this.view.renderError(snapshot, this.currentVisit) } else { await this.view.renderPage(snapshot, false, true, this.currentVisit) } if (snapshot.refreshScroll !== "preserve") { this.view.scrollToTop() } this.view.clearSnapshotCache() } } formSubmissionErrored(formSubmission, error) { console.error(error) } formSubmissionFinished(formSubmission) { // Not all adapters implement formSubmissionFinished if (typeof this.adapter.formSubmissionFinished === "function") { this.adapter.formSubmissionFinished(formSubmission) } } // Link prefetching linkPrefetchingIsEnabledForLocation(location) { // Not all adapters implement linkPrefetchingIsEnabledForLocation if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") { return this.adapter.linkPrefetchingIsEnabledForLocation(location) } return true } // Visit delegate visitStarted(visit) { this.delegate.visitStarted(visit) } visitCompleted(visit) { this.delegate.visitCompleted(visit) delete this.currentVisit } // Same-page links are no longer handled with a Visit. // This method is still needed for Turbo Native adapters. locationWithActionIsSamePage(location, action) { return false } // Visits get location() { return this.history.location } get restorationIdentifier() { return this.history.restorationIdentifier } #getActionForFormSubmission(formSubmission, fetchResponse) { const { submitter, formElement } = formSubmission return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse) } #getDefaultAction(fetchResponse) { const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href return sameLocationRedirect ? "replace" : "advance" } } ================================================ FILE: src/core/drive/page_renderer.js ================================================ import { activateScriptElement, elementIsStylesheet, waitForLoad } from "../../util" import { Renderer } from "../renderer" export class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement) } else { document.documentElement.appendChild(newElement) } } get shouldRender() { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } get reloadReason() { if (!this.newSnapshot.isVisitable) { return { reason: "turbo_visit_control_is_reload" } } if (!this.trackedElementsAreIdentical) { return { reason: "tracked_element_mismatch" } } } async prepareToRender() { this.#setLanguage() await this.mergeHead() } async render() { if (this.willRender) { await this.replaceBody() } } finishRendering() { super.finishRendering() if (!this.isPreview) { this.focusFirstAutofocusableElement() } } get currentHeadSnapshot() { return this.currentSnapshot.headSnapshot } get newHeadSnapshot() { return this.newSnapshot.headSnapshot } get newElement() { return this.newSnapshot.element } #setLanguage() { const { documentElement } = this.currentSnapshot const { dir, lang } = this.newSnapshot if (lang) { documentElement.setAttribute("lang", lang) } else { documentElement.removeAttribute("lang") } if (dir) { documentElement.setAttribute("dir", dir) } else { documentElement.removeAttribute("dir") } } async mergeHead() { const mergedHeadElements = this.mergeProvisionalElements() const newStylesheetElements = this.copyNewHeadStylesheetElements() this.copyNewHeadScriptElements() await mergedHeadElements await newStylesheetElements if (this.willRender) { this.removeUnusedDynamicStylesheetElements() } } async replaceBody() { await this.preservingPermanentElements(async () => { this.activateNewBody() await this.assignNewBody() }) } get trackedElementsAreIdentical() { return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature } async copyNewHeadStylesheetElements() { const loadingElements = [] for (const element of this.newHeadStylesheetElements) { loadingElements.push(waitForLoad(element)) document.head.appendChild(element) } await Promise.all(loadingElements) } copyNewHeadScriptElements() { for (const element of this.newHeadScriptElements) { document.head.appendChild(activateScriptElement(element)) } } removeUnusedDynamicStylesheetElements() { for (const element of this.unusedDynamicStylesheetElements) { document.head.removeChild(element) } } async mergeProvisionalElements() { const newHeadElements = [...this.newHeadProvisionalElements] for (const element of this.currentHeadProvisionalElements) { if (!this.isCurrentElementInElementList(element, newHeadElements)) { document.head.removeChild(element) } } for (const element of newHeadElements) { document.head.appendChild(element) } } isCurrentElementInElementList(element, elementList) { for (const [index, newElement] of elementList.entries()) { // if title element... if (element.tagName == "TITLE") { if (newElement.tagName != "TITLE") { continue } if (element.innerHTML == newElement.innerHTML) { elementList.splice(index, 1) return true } } // if any other element... if (newElement.isEqualNode(element)) { elementList.splice(index, 1) return true } } return false } removeCurrentHeadProvisionalElements() { for (const element of this.currentHeadProvisionalElements) { document.head.removeChild(element) } } copyNewHeadProvisionalElements() { for (const element of this.newHeadProvisionalElements) { document.head.appendChild(element) } } activateNewBody() { document.adoptNode(this.newElement) this.deactivateNoscriptStylesheetElements() this.activateNewBodyScriptElements() } deactivateNoscriptStylesheetElements() { for (const noscriptElement of this.newElement.querySelectorAll("noscript")) { for (const child of [...noscriptElement.children]) { if (elementIsStylesheet(child)) { child.remove() } } } } activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } async assignNewBody() { await this.renderElement(this.currentElement, this.newElement) } get unusedDynamicStylesheetElements() { return this.oldHeadStylesheetElements.filter((element) => { return element.getAttribute("data-turbo-track") === "dynamic" }) } get oldHeadStylesheetElements() { return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot) } get newHeadStylesheetElements() { return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot) } get newHeadScriptElements() { return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot) } get currentHeadProvisionalElements() { return this.currentHeadSnapshot.provisionalElements } get newHeadProvisionalElements() { return this.newHeadSnapshot.provisionalElements } get newBodyScriptElements() { return this.newElement.querySelectorAll("script") } } ================================================ FILE: src/core/drive/page_snapshot.js ================================================ import { elementIsStylesheet, parseHTMLDocument } from "../../util" import { Snapshot } from "../snapshot" import { expandURL } from "../url" import { HeadSnapshot } from "./head_snapshot" export class PageSnapshot extends Snapshot { static fromHTMLString(html = "") { return this.fromDocument(parseHTMLDocument(html)) } static fromElement(element) { return this.fromDocument(element.ownerDocument) } static fromDocument({ documentElement, body, head }) { return new this(documentElement, body, new HeadSnapshot(head)) } constructor(documentElement, body, headSnapshot) { super(body) this.documentElement = documentElement this.headSnapshot = headSnapshot } clone() { const clonedElement = this.element.cloneNode(true) const selectElements = this.element.querySelectorAll("select") const clonedSelectElements = clonedElement.querySelectorAll("select") for (const [index, source] of selectElements.entries()) { const clone = clonedSelectElements[index] for (const option of clone.selectedOptions) option.selected = false for (const option of source.selectedOptions) clone.options[option.index].selected = true } for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { clonedPasswordInput.value = "" } for (const clonedNoscriptElement of clonedElement.querySelectorAll("noscript")) { for (const child of [...clonedNoscriptElement.children]) { if (elementIsStylesheet(child)) { child.remove() } } } return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot) } get lang() { return this.documentElement.getAttribute("lang") } get dir() { return this.documentElement.getAttribute("dir") } get headElement() { return this.headSnapshot.element } get rootLocation() { const root = this.getSetting("root") ?? "/" return expandURL(root) } get cacheControlValue() { return this.getSetting("cache-control") } get isPreviewable() { return this.cacheControlValue != "no-preview" } get isCacheable() { return this.cacheControlValue != "no-cache" } get isVisitable() { return this.getSetting("visit-control") != "reload" } get prefersViewTransitions() { const viewTransitionEnabled = this.getSetting("view-transition") === "true" || this.headSnapshot.getMetaValue("view-transition") === "same-origin" return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches } get refreshMethod() { return this.getSetting("refresh-method") } get refreshScroll() { return this.getSetting("refresh-scroll") } // Private getSetting(name) { return this.headSnapshot.getMetaValue(`turbo-${name}`) } } ================================================ FILE: src/core/drive/page_view.js ================================================ import { nextEventLoopTick } from "../../util" import { View } from "../view" import { ErrorRenderer } from "./error_renderer" import { MorphingPageRenderer } from "./morphing_page_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" export class PageView extends View { snapshotCache = new SnapshotCache(10) lastRenderedLocation = new URL(location.href) forceReloaded = false shouldTransitionTo(newSnapshot) { return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions } renderPage(snapshot, isPreview = false, willRender = true, visit) { const shouldMorphPage = this.isPageRefresh(visit) && (visit?.refresh?.method || this.snapshot.refreshMethod) === "morph" const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true } else { visit?.changeHistory() } return this.render(renderer) } renderError(snapshot, visit) { visit?.changeHistory() const renderer = new ErrorRenderer(this.snapshot, snapshot, false) return this.render(renderer) } clearSnapshotCache() { this.snapshotCache.clear() } async cacheSnapshot(snapshot = this.snapshot) { if (snapshot.isCacheable) { this.delegate.viewWillCacheSnapshot() const { lastRenderedLocation: location } = this await nextEventLoopTick() const cachedSnapshot = snapshot.clone() this.snapshotCache.put(location, cachedSnapshot) return cachedSnapshot } } getCachedSnapshotForLocation(location) { return this.snapshotCache.get(location) } isPageRefresh(visit) { return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace") } shouldPreserveScrollPosition(visit) { return this.isPageRefresh(visit) && (visit?.refresh?.scroll || this.snapshot.refreshScroll) === "preserve" } get snapshot() { return PageSnapshot.fromElement(this.element) } } ================================================ FILE: src/core/drive/prefetch_cache.js ================================================ import { LRUCache } from "../lru_cache" import { toCacheKey } from "../url" const PREFETCH_DELAY = 100 class PrefetchCache extends LRUCache { #prefetchTimeout = null #maxAges = {} constructor(size = 1, prefetchDelay = PREFETCH_DELAY) { super(size, toCacheKey) this.prefetchDelay = prefetchDelay } putLater(url, request, ttl) { this.#prefetchTimeout = setTimeout(() => { request.perform() this.put(url, request, ttl) this.#prefetchTimeout = null }, this.prefetchDelay) } put(url, request, ttl = cacheTtl) { super.put(url, request) this.#maxAges[toCacheKey(url)] = new Date(new Date().getTime() + ttl) } clear() { super.clear() if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout) } evict(key) { super.evict(key) delete this.#maxAges[key] } has(key) { if (super.has(key)) { const maxAge = this.#maxAges[toCacheKey(key)] return maxAge && maxAge > Date.now() } else { return false } } } export const cacheTtl = 10 * 1000 export const prefetchCache = new PrefetchCache() ================================================ FILE: src/core/drive/preloader.js ================================================ import { PageSnapshot } from "./page_snapshot" import { FetchMethod, FetchRequest } from "../../http/fetch_request" export class Preloader { selector = "a[data-turbo-preload]" constructor(delegate, snapshotCache) { this.delegate = delegate this.snapshotCache = snapshotCache } start() { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", this.#preloadAll) } else { this.preloadOnLoadLinksForView(document.body) } } stop() { document.removeEventListener("DOMContentLoaded", this.#preloadAll) } preloadOnLoadLinksForView(element) { for (const link of element.querySelectorAll(this.selector)) { if (this.delegate.shouldPreloadLink(link)) { this.preloadURL(link) } } } async preloadURL(link) { const location = new URL(link.href) if (this.snapshotCache.has(location)) { return } const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link) await fetchRequest.perform() } // Fetch request delegate prepareRequest(fetchRequest) { fetchRequest.headers["X-Sec-Purpose"] = "prefetch" } async requestSucceededWithResponse(fetchRequest, fetchResponse) { try { const responseHTML = await fetchResponse.responseHTML const snapshot = PageSnapshot.fromHTMLString(responseHTML) this.snapshotCache.put(fetchRequest.url, snapshot) } catch (_) { // If we cannot preload that is ok! } } requestStarted(fetchRequest) {} requestErrored(fetchRequest) {} requestFinished(fetchRequest) {} requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} requestFailedWithResponse(fetchRequest, fetchResponse) {} #preloadAll = () => { this.preloadOnLoadLinksForView(document.body) } } ================================================ FILE: src/core/drive/progress_bar.js ================================================ import { unindent, getCspNonce } from "../../util" export const ProgressBarID = "turbo-progress-bar" export class ProgressBar { static animationDuration = 300 /*ms*/ static get defaultCSS() { return unindent` .turbo-progress-bar { position: fixed; display: block; top: 0; left: 0; height: 3px; background: #0076ff; z-index: 2147483647; transition: width ${ProgressBar.animationDuration}ms ease-out, opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in; transform: translate3d(0, 0, 0); } ` } hiding = false value = 0 visible = false constructor() { this.stylesheetElement = this.createStylesheetElement() this.progressElement = this.createProgressElement() this.installStylesheetElement() this.setValue(0) } show() { if (!this.visible) { this.visible = true this.installProgressElement() this.startTrickling() } } hide() { if (this.visible && !this.hiding) { this.hiding = true this.fadeProgressElement(() => { this.uninstallProgressElement() this.stopTrickling() this.visible = false this.hiding = false }) } } setValue(value) { this.value = value this.refresh() } // Private installStylesheetElement() { document.head.insertBefore(this.stylesheetElement, document.head.firstChild) } installProgressElement() { this.progressElement.style.width = "0" this.progressElement.style.opacity = "1" document.documentElement.insertBefore(this.progressElement, document.body) this.refresh() } fadeProgressElement(callback) { this.progressElement.style.opacity = "0" setTimeout(callback, ProgressBar.animationDuration * 1.5) } uninstallProgressElement() { if (this.progressElement.parentNode) { document.documentElement.removeChild(this.progressElement) } } startTrickling() { if (!this.trickleInterval) { this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration) } } stopTrickling() { window.clearInterval(this.trickleInterval) delete this.trickleInterval } trickle = () => { this.setValue(this.value + Math.random() / 100) } refresh() { requestAnimationFrame(() => { this.progressElement.style.width = `${10 + this.value * 90}%` }) } createStylesheetElement() { const element = document.createElement("style") element.type = "text/css" element.textContent = ProgressBar.defaultCSS const cspNonce = getCspNonce() if (cspNonce) { element.nonce = cspNonce } return element } createProgressElement() { const element = document.createElement("div") element.className = "turbo-progress-bar" return element } } ================================================ FILE: src/core/drive/snapshot_cache.js ================================================ import { toCacheKey } from "../url" import { LRUCache } from "../lru_cache" export class SnapshotCache extends LRUCache { constructor(size) { super(size, toCacheKey) } get snapshots() { return this.entries } } ================================================ FILE: src/core/drive/view_transitioner.js ================================================ 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 } } ================================================ FILE: src/core/drive/visit.js ================================================ import { FetchMethod, FetchRequest } from "../../http/fetch_request" import { getAnchor } from "../url" import { PageSnapshot } from "./page_snapshot" import { getHistoryMethodForAction, uuid } from "../../util" import { StreamMessage } from "../streams/stream_message" import { ViewTransitioner } from "./view_transitioner" const defaultOptions = { action: "advance", historyChanged: false, visitCachedSnapshot: () => {}, willRender: true, updateHistory: true, shouldCacheSnapshot: true, acceptsStreamResponse: false, refresh: {} } export const TimingMetric = { visitStart: "visitStart", requestStart: "requestStart", requestEnd: "requestEnd", visitEnd: "visitEnd" } export const VisitState = { initialized: "initialized", started: "started", canceled: "canceled", failed: "failed", completed: "completed" } export const SystemStatusCode = { networkFailure: 0, timeoutFailure: -1, contentTypeMismatch: -2 } export const Direction = { advance: "forward", restore: "back", replace: "none" } export class Visit { identifier = uuid() // Required by turbo-ios timingMetrics = {} followedRedirect = false historyChanged = false scrolled = false shouldCacheSnapshot = true acceptsStreamResponse = false snapshotCached = false state = VisitState.initialized viewTransitioner = new ViewTransitioner() constructor(delegate, location, restorationIdentifier, options = {}) { this.delegate = delegate this.location = location this.restorationIdentifier = restorationIdentifier || uuid() const { action, historyChanged, referrer, snapshot, snapshotHTML, response, visitCachedSnapshot, willRender, updateHistory, shouldCacheSnapshot, acceptsStreamResponse, direction, refresh } = { ...defaultOptions, ...options } this.action = action this.historyChanged = historyChanged this.referrer = referrer this.snapshot = snapshot this.snapshotHTML = snapshotHTML this.response = response this.isPageRefresh = this.view.isPageRefresh(this) this.visitCachedSnapshot = visitCachedSnapshot this.willRender = willRender this.updateHistory = updateHistory this.scrolled = !willRender this.shouldCacheSnapshot = shouldCacheSnapshot this.acceptsStreamResponse = acceptsStreamResponse this.direction = direction || Direction[action] this.refresh = refresh } get adapter() { return this.delegate.adapter } get view() { return this.delegate.view } get history() { return this.delegate.history } get restorationData() { return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) } start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart) this.state = VisitState.started this.adapter.visitStarted(this) this.delegate.visitStarted(this) } } cancel() { if (this.state == VisitState.started) { if (this.request) { this.request.cancel() } this.cancelRender() this.state = VisitState.canceled } } complete() { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd) this.adapter.visitCompleted(this) this.state = VisitState.completed this.followRedirect() if (!this.followedRedirect) { this.delegate.visitCompleted(this) } } } fail() { if (this.state == VisitState.started) { this.state = VisitState.failed this.adapter.visitFailed(this) this.delegate.visitCompleted(this) } } changeHistory() { if (!this.historyChanged && this.updateHistory) { const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action const method = getHistoryMethodForAction(actionForHistory) this.history.update(method, this.location, this.restorationIdentifier) this.historyChanged = true } } issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest() } else if (this.shouldIssueRequest() && !this.request) { this.request = new FetchRequest(this, FetchMethod.get, this.location) this.request.perform() } } simulateRequest() { if (this.response) { this.startRequest() this.recordResponse() this.finishRequest() } } startRequest() { this.recordTimingMetric(TimingMetric.requestStart) this.adapter.visitRequestStarted(this) } recordResponse(response = this.response) { this.response = response if (response) { const { statusCode } = response if (isSuccessful(statusCode)) { this.adapter.visitRequestCompleted(this) } else { this.adapter.visitRequestFailedWithStatusCode(this, statusCode) } } } finishRequest() { this.recordTimingMetric(TimingMetric.requestEnd) this.adapter.visitRequestFinished(this) } loadResponse() { if (this.response) { const { statusCode, responseHTML } = this.response this.render(async () => { if (this.shouldCacheSnapshot) this.cacheSnapshot() if (this.view.renderPromise) await this.view.renderPromise if (isSuccessful(statusCode) && responseHTML != null) { const snapshot = PageSnapshot.fromHTMLString(responseHTML) await this.renderPageSnapshot(snapshot, false) this.adapter.visitRendered(this) this.complete() } else { await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this) this.adapter.visitRendered(this) this.fail() } }) } } getCachedSnapshot() { const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot() if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { if (this.action == "restore" || snapshot.isPreviewable) { return snapshot } } } getPreloadedSnapshot() { if (this.snapshotHTML) { return PageSnapshot.fromHTMLString(this.snapshotHTML) } } hasCachedSnapshot() { return this.getCachedSnapshot() != null } loadCachedSnapshot() { const snapshot = this.getCachedSnapshot() if (snapshot) { const isPreview = this.shouldIssueRequest() this.render(async () => { this.cacheSnapshot() if (this.isPageRefresh) { this.adapter.visitRendered(this) } else { if (this.view.renderPromise) await this.view.renderPromise await this.renderPageSnapshot(snapshot, isPreview) this.adapter.visitRendered(this) if (!isPreview) { this.complete() } } }) } } followRedirect() { if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { action: "replace", response: this.response, shouldCacheSnapshot: false, willRender: false }) this.followedRedirect = true } } // Fetch request delegate prepareRequest(request) { if (this.acceptsStreamResponse) { request.acceptResponseType(StreamMessage.contentType) } } requestStarted() { this.startRequest() } requestPreventedHandlingResponse(_request, _response) {} async requestSucceededWithResponse(request, response) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, redirected }) } else { this.redirectedToLocation = response.redirected ? response.location : undefined this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } async requestFailedWithResponse(request, response) { const responseHTML = await response.responseHTML const { redirected, statusCode } = response if (responseHTML == undefined) { this.recordResponse({ statusCode: SystemStatusCode.contentTypeMismatch, redirected }) } else { this.recordResponse({ statusCode: statusCode, responseHTML, redirected }) } } requestErrored(_request, _error) { this.recordResponse({ statusCode: SystemStatusCode.networkFailure, redirected: false }) } requestFinished() { this.finishRequest() } // Scrolling performScroll() { if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop() } else { this.scrollToAnchor() || this.view.scrollToTop() } this.scrolled = true } } scrollToRestoredPosition() { const { scrollPosition } = this.restorationData if (scrollPosition) { this.view.scrollToPosition(scrollPosition) return true } } scrollToAnchor() { const anchor = getAnchor(this.location) if (anchor != null) { this.view.scrollToAnchor(anchor) return true } } // Instrumentation recordTimingMetric(metric) { this.timingMetrics[metric] = new Date().getTime() } getTimingMetrics() { return { ...this.timingMetrics } } // Private hasPreloadedResponse() { return typeof this.response == "object" } shouldIssueRequest() { if (this.action == "restore") { return !this.hasCachedSnapshot() } else { return this.willRender } } cacheSnapshot() { if (!this.snapshotCached) { this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)) this.snapshotCached = true } } async render(callback) { this.cancelRender() await new Promise((resolve) => { this.frame = document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve()) }) await callback() delete this.frame } async renderPageSnapshot(snapshot, isPreview) { await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { await this.view.renderPage(snapshot, isPreview, this.willRender, this) this.performScroll() }) } cancelRender() { if (this.frame) { cancelAnimationFrame(this.frame) delete this.frame } } } function isSuccessful(statusCode) { return statusCode >= 200 && statusCode < 300 } ================================================ FILE: src/core/errors.js ================================================ export class TurboFrameMissingError extends Error {} ================================================ FILE: src/core/frames/frame_controller.js ================================================ import { FrameElement, FrameLoadingStyle } from "../../elements/frame_element" import { FetchMethod, FetchRequest } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { AppearanceObserver } from "../../observers/appearance_observer" import { clearBusyState, dispatch, getAttribute, parseHTMLDocument, markAsBusy, uuid, getHistoryMethodForAction, getVisitAction } from "../../util" import { FormSubmission } from "../drive/form_submission" import { Snapshot } from "../snapshot" import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" import { FormSubmitObserver } from "../../observers/form_submit_observer" import { FrameView } from "./frame_view" import { LinkInterceptor } from "./link_interceptor" import { FormLinkClickObserver } from "../../observers/form_link_click_observer" import { FrameRenderer } from "./frame_renderer" import { MorphingFrameRenderer } from "./morphing_frame_renderer" import { session } from "../index" import { StreamMessage } from "../streams/stream_message" import { PageSnapshot } from "../drive/page_snapshot" import { TurboFrameMissingError } from "../errors" export class FrameController { fetchResponseLoaded = (_fetchResponse) => Promise.resolve() #currentFetchRequest = null #resolveVisitPromise = () => {} #connected = false #hasBeenLoaded = false #ignoredAttributes = new Set() #shouldMorphFrame = false action = null constructor(element) { this.element = element this.view = new FrameView(this, this.element) this.appearanceObserver = new AppearanceObserver(this, this.element) this.formLinkClickObserver = new FormLinkClickObserver(this, this.element) this.linkInterceptor = new LinkInterceptor(this, this.element) this.restorationIdentifier = uuid() this.formSubmitObserver = new FormSubmitObserver(this, this.element) } // Frame delegate connect() { if (!this.#connected) { this.#connected = true if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() } else { this.#loadSourceURL() } this.formLinkClickObserver.start() this.linkInterceptor.start() this.formSubmitObserver.start() } } disconnect() { if (this.#connected) { this.#connected = false this.appearanceObserver.stop() this.formLinkClickObserver.stop() this.linkInterceptor.stop() this.formSubmitObserver.stop() if (!this.element.hasAttribute("recurse")) { this.#currentFetchRequest?.cancel() } } } disabledChanged() { if (this.disabled) { this.#currentFetchRequest?.cancel() } else if (this.loadingStyle == FrameLoadingStyle.eager) { this.#loadSourceURL() } } sourceURLChanged() { if (this.#isIgnoringChangesTo("src")) return if (!this.sourceURL) { this.#currentFetchRequest?.cancel() } if (this.element.isConnected) { this.complete = false } if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { this.#loadSourceURL() } } sourceURLReloaded() { const { refresh, src } = this.element this.#shouldMorphFrame = src && refresh === "morph" this.element.removeAttribute("complete") this.element.src = null this.element.src = src return this.element.loaded } loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() } else { this.appearanceObserver.stop() this.#loadSourceURL() } } async #loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { this.element.loaded = this.#visit(expandURL(this.sourceURL)) this.appearanceObserver.stop() await this.element.loaded this.#hasBeenLoaded = true } } async loadResponse(fetchResponse) { if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } try { const html = await fetchResponse.responseHTML if (html) { const document = parseHTMLDocument(html) const pageSnapshot = PageSnapshot.fromDocument(document) if (pageSnapshot.isVisitable) { await this.#loadFrameResponse(fetchResponse, document) } else { await this.#handleUnvisitableFrameResponse(fetchResponse) } } } finally { this.#shouldMorphFrame = false this.fetchResponseLoaded = () => Promise.resolve() } } // Appearance observer delegate elementAppearedInViewport(element) { this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)) this.#loadSourceURL() } // Form link click observer delegate willSubmitFormLinkToLocation(link) { return this.#shouldInterceptNavigation(link) } submittedFormLinkToLocation(link, _location, form) { 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) } linkClickIntercepted(element, location) { this.#navigateFrame(element, location) } // Form submit observer delegate willSubmitForm(element, submitter) { return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter) } formSubmitted(element, submitter) { if (this.formSubmission) { this.formSubmission.stop() } this.formSubmission = new FormSubmission(this, element, submitter) const { fetchRequest } = this.formSubmission const frame = this.#findFrameElement(element, submitter) this.prepareRequest(fetchRequest, frame) this.formSubmission.start() } // Fetch request delegate prepareRequest(request, frame = this) { request.headers["Turbo-Frame"] = frame.id if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { request.acceptResponseType(StreamMessage.contentType) } } requestStarted(_request) { markAsBusy(this.element) } requestPreventedHandlingResponse(_request, _response) { this.#resolveVisitPromise() } async requestSucceededWithResponse(request, response) { await this.loadResponse(response) this.#resolveVisitPromise() } async requestFailedWithResponse(request, response) { await this.loadResponse(response) this.#resolveVisitPromise() } requestErrored(request, error) { console.error(error) this.#resolveVisitPromise() } requestFinished(_request) { clearBusyState(this.element) } // Form submission delegate formSubmissionStarted({ formElement }) { markAsBusy(formElement, this.#findFrameElement(formElement)) } formSubmissionSucceededWithResponse(formSubmission, response) { const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)) frame.delegate.loadResponse(response) if (!formSubmission.isSafe) { session.clearCache() } } formSubmissionFailedWithResponse(formSubmission, fetchResponse) { this.element.delegate.loadResponse(fetchResponse) session.clearCache() } formSubmissionErrored(formSubmission, error) { console.error(error) } formSubmissionFinished({ formElement }) { clearBusyState(formElement, this.#findFrameElement(formElement)) } // View delegate allowsImmediateRender({ element: newFrame }, options) { const event = dispatch("turbo:before-frame-render", { target: this.element, detail: { newFrame, ...options }, cancelable: true }) const { defaultPrevented, detail: { render } } = event if (this.view.renderer && render) { this.view.renderer.renderElement = render } return !defaultPrevented } viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element) } viewInvalidated() {} // Frame renderer delegate willRenderFrame(currentElement, _newElement) { this.previousFrameElement = currentElement.cloneNode(true) } visitCachedSnapshot = ({ element }) => { const frame = element.querySelector("#" + this.element.id) if (frame && this.previousFrameElement) { frame.replaceChildren(...this.previousFrameElement.children) } delete this.previousFrameElement } // Private async #loadFrameResponse(fetchResponse, document) { const newFrameElement = await this.extractForeignFrameElement(document.body) const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer if (newFrameElement) { const snapshot = new Snapshot(newFrameElement) const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false) if (this.view.renderPromise) await this.view.renderPromise this.changeHistory() await this.view.render(renderer) this.complete = true session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) await this.fetchResponseLoaded(fetchResponse) } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { this.#handleFrameMissingFromResponse(fetchResponse) } } async #visit(url) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) this.#currentFetchRequest?.cancel() this.#currentFetchRequest = request return new Promise((resolve) => { this.#resolveVisitPromise = () => { this.#resolveVisitPromise = () => {} this.#currentFetchRequest = null resolve() } request.perform() }) } #navigateFrame(element, url, submitter) { const frame = this.#findFrameElement(element, submitter) frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame)) this.#withCurrentNavigationElement(element, () => { frame.src = url }) } proposeVisitIfNavigatedWithAction(frame, action = null) { this.action = action if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone() const { visitCachedSnapshot } = frame.delegate frame.delegate.fetchResponseLoaded = async (fetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse const responseHTML = await fetchResponse.responseHTML const response = { statusCode, redirected, responseHTML } const options = { response, visitCachedSnapshot, willRender: false, updateHistory: false, restorationIdentifier: this.restorationIdentifier, snapshot: pageSnapshot } if (this.action) options.action = this.action session.visit(frame.src, options) } } } } changeHistory() { if (this.action) { const method = getHistoryMethodForAction(this.action) session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier) } } async #handleUnvisitableFrameResponse(fetchResponse) { console.warn( `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` ) await this.#visitResponse(fetchResponse.response) } #willHandleFrameMissingFromResponse(fetchResponse) { this.element.setAttribute("complete", "") const response = fetchResponse.response const visit = async (url, options) => { if (url instanceof Response) { this.#visitResponse(url) } else { session.visit(url, options) } } const event = dispatch("turbo:frame-missing", { target: this.element, detail: { response, visit }, cancelable: true }) return !event.defaultPrevented } #handleFrameMissingFromResponse(fetchResponse) { this.view.missing() this.#throwFrameMissingError(fetchResponse) } #throwFrameMissingError(fetchResponse) { const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.` throw new TurboFrameMissingError(message) } async #visitResponse(response) { const wrapped = new FetchResponse(response) const responseHTML = await wrapped.responseHTML const { location, redirected, statusCode } = wrapped return session.visit(location, { response: { redirected, statusCode, responseHTML } }) } #findFrameElement(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") const target = this.#getFrameElementById(id) return target instanceof FrameElement ? target : this.element } async extractForeignFrameElement(container) { let element const id = CSS.escape(this.id) try { element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL) if (element) { return element } element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL) if (element) { await element.loaded return await this.extractForeignFrameElement(element) } } catch (error) { console.error(error) return new FrameElement() } return null } #formActionIsVisitable(form, submitter) { const action = getAction(form, submitter) return locationIsVisitable(expandURL(action), this.rootLocation) } #shouldInterceptNavigation(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { return false } if (!this.enabled || id == "_top") { return false } if (id) { const frameElement = this.#getFrameElementById(id) if (frameElement) { return !frameElement.disabled } else if (id == "_parent") { return false } } if (!session.elementIsNavigatable(element)) { return false } if (submitter && !session.elementIsNavigatable(submitter)) { return false } return true } // Computed properties get id() { return this.element.id } get disabled() { return this.element.disabled } get enabled() { return !this.disabled } get sourceURL() { if (this.element.src) { return this.element.src } } set sourceURL(sourceURL) { this.#ignoringChangesToAttribute("src", () => { this.element.src = sourceURL ?? null }) } get loadingStyle() { return this.element.loading } get isLoading() { return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined } get complete() { return this.element.hasAttribute("complete") } set complete(value) { if (value) { this.element.setAttribute("complete", "") } else { this.element.removeAttribute("complete") } } get isActive() { return this.element.isActive && this.#connected } get rootLocation() { const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const root = meta?.content ?? "/" return expandURL(root) } #isIgnoringChangesTo(attributeName) { return this.#ignoredAttributes.has(attributeName) } #ignoringChangesToAttribute(attributeName, callback) { this.#ignoredAttributes.add(attributeName) callback() this.#ignoredAttributes.delete(attributeName) } #withCurrentNavigationElement(element, callback) { this.currentNavigationElement = element callback() delete this.currentNavigationElement } #getFrameElementById(id) { if (id != null) { const element = id === "_parent" ? this.element.parentElement.closest("turbo-frame") : document.getElementById(id) if (element instanceof FrameElement) { return element } } } } function activateElement(element, currentURL) { if (element) { const src = element.getAttribute("src") if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { throw new Error(`Matching element has a source URL which references itself`) } if (element.ownerDocument !== document) { element = document.importNode(element, true) } if (element instanceof FrameElement) { element.connectedCallback() element.disconnectedCallback() return element } } } ================================================ FILE: src/core/frames/frame_redirector.js ================================================ 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 } } } } ================================================ FILE: src/core/frames/frame_renderer.js ================================================ import { activateScriptElement, nextRepaint } from "../../util" import { Renderer } from "../renderer" export class FrameRenderer extends Renderer { static renderElement(currentElement, newElement) { const destinationRange = document.createRange() destinationRange.selectNodeContents(currentElement) destinationRange.deleteContents() const frameElement = newElement const sourceRange = frameElement.ownerDocument?.createRange() if (sourceRange) { sourceRange.selectNodeContents(frameElement) currentElement.appendChild(sourceRange.extractContents()) } } constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate } get shouldRender() { return true } async render() { await nextRepaint() this.preservingPermanentElements(() => { this.loadFrameElement() }) this.scrollFrameIntoView() await nextRepaint() this.focusFirstAutofocusableElement() await nextRepaint() this.activateScriptElements() } loadFrameElement() { this.delegate.willRenderFrame(this.currentElement, this.newElement) this.renderElement(this.currentElement, this.newElement) } scrollFrameIntoView() { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end") const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto") if (element) { element.scrollIntoView({ block, behavior }) return true } } return false } activateScriptElements() { for (const inertScriptElement of this.newScriptElements) { const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } get newScriptElements() { return this.currentElement.querySelectorAll("script") } } function readScrollLogicalPosition(value, defaultValue) { if (value == "end" || value == "start" || value == "center" || value == "nearest") { return value } else { return defaultValue } } function readScrollBehavior(value, defaultValue) { if (value == "auto" || value == "smooth") { return value } else { return defaultValue } } ================================================ FILE: src/core/frames/frame_view.js ================================================ 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) } } ================================================ FILE: src/core/frames/link_interceptor.js ================================================ import { findLinkFromClickTarget } from "../../util" export class LinkInterceptor { constructor(delegate, element) { this.delegate = delegate this.element = element } start() { this.element.addEventListener("click", this.clickBubbled) document.addEventListener("turbo:click", this.linkClicked) document.addEventListener("turbo:before-visit", this.willVisit) } stop() { this.element.removeEventListener("click", this.clickBubbled) document.removeEventListener("turbo:click", this.linkClicked) document.removeEventListener("turbo:before-visit", this.willVisit) } clickBubbled = (event) => { if (this.clickEventIsSignificant(event)) { this.clickEvent = event } else { delete this.clickEvent } } linkClicked = (event) => { if (this.clickEvent && this.clickEventIsSignificant(event)) { if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { this.clickEvent.preventDefault() event.preventDefault() this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent) } } delete this.clickEvent } willVisit = (_event) => { delete this.clickEvent } clickEventIsSignificant(event) { const target = event.composed ? event.target?.parentElement : event.target const element = findLinkFromClickTarget(target) || target return element instanceof Element && element.closest("turbo-frame, html") == this.element } } ================================================ FILE: src/core/frames/morphing_frame_renderer.js ================================================ import { FrameRenderer } from "./frame_renderer" import { morphChildren, shouldRefreshFrameWithMorphing, closestFrameReloadableWithMorphing } from "../morphing" import { dispatch } from "../../util" export class MorphingFrameRenderer extends FrameRenderer { static renderElement(currentElement, newElement) { dispatch("turbo:before-frame-morph", { target: currentElement, detail: { currentElement, newElement } }) morphChildren(currentElement, newElement, { callbacks: { beforeNodeMorphed: (node, newNode) => { if ( shouldRefreshFrameWithMorphing(node, newNode) && closestFrameReloadableWithMorphing(node) === currentElement ) { node.reload() return false } return true } } }) } async preservingPermanentElements(callback) { return await callback() } } ================================================ FILE: src/core/index.js ================================================ import { Session } from "./session" import { PageRenderer } from "./drive/page_renderer" import { PageSnapshot } from "./drive/page_snapshot" import { FrameRenderer } from "./frames/frame_renderer" import { fetch, recentRequests } from "../http/fetch" import { config } from "./config" import { MorphingPageRenderer } from "./drive/morphing_page_renderer" import { MorphingFrameRenderer } from "./frames/morphing_frame_renderer" export { morphChildren, morphElements } from "./morphing" export { PageRenderer, PageSnapshot, FrameRenderer, fetch, config } const session = new Session(recentRequests) // Rename `navigator` to avoid shadowing `window.navigator` const { cache, navigator: sessionNavigator } = session export { session, cache, sessionNavigator as navigator } /** * Starts the main session. * This initialises any necessary observers such as those to monitor * link interactions. */ export function start() { session.start() } /** * Registers an adapter for the main session. * * @param adapter Adapter to register */ export function registerAdapter(adapter) { session.registerAdapter(adapter) } /** * Performs an application visit to the given location. * * @param location Location to visit (a URL or path) * @param options Options to apply * @param options.action Type of history navigation to apply ("restore", * "replace" or "advance") * @param options.historyChanged Specifies whether the browser history has * already been changed for this visit or not * @param options.referrer Specifies the referrer of this visit such that * navigations to the same page will not result in a new history entry. * @param options.snapshotHTML Cached snapshot to render * @param options.response Response of the specified location */ export function visit(location, options) { session.visit(location, options) } /** * Connects a stream source to the main session. * * @param source Stream source to connect */ export function connectStreamSource(source) { session.connectStreamSource(source) } /** * Disconnects a stream source from the main session. * * @param source Stream source to disconnect */ export function disconnectStreamSource(source) { session.disconnectStreamSource(source) } /** * Renders a stream message to the main session by appending it to the * current document. * * @param message Message to render */ export function renderStreamMessage(message) { session.renderStreamMessage(message) } /** * Sets the delay after which the progress bar will appear during navigation. * * The progress bar appears after 500ms by default. * * Note that this method has no effect when used with the iOS or Android * adapters. * * @param delay Time to delay in milliseconds */ export function setProgressBarDelay(delay) { console.warn( "Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ) config.drive.progressBarDelay = delay } export function setConfirmMethod(confirmMethod) { console.warn( "Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ) config.forms.confirm = confirmMethod } export function setFormMode(mode) { console.warn( "Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`" ) config.forms.mode = mode } /** * Morph the state of the currentBody based on the attributes and contents of * the newBody. Morphing body elements may dispatch turbo:morph, * turbo:before-morph-element, turbo:before-morph-attribute, and * turbo:morph-element events. * * @param currentBody HTMLBodyElement destination of morphing changes * @param newBody HTMLBodyElement source of morphing changes */ export function morphBodyElements(currentBody, newBody) { MorphingPageRenderer.renderElement(currentBody, newBody) } /** * Morph the child elements of the currentFrame based on the child elements of * the newFrame. Morphing turbo-frame elements may dispatch turbo:before-frame-morph, * turbo:before-morph-element, turbo:before-morph-attribute, and * turbo:morph-element events. * * @param currentFrame FrameElement destination of morphing children changes * @param newFrame FrameElement source of morphing children changes */ export function morphTurboFrameElements(currentFrame, newFrame) { MorphingFrameRenderer.renderElement(currentFrame, newFrame) } ================================================ FILE: src/core/lru_cache.js ================================================ const identity = key => key export class LRUCache { keys = [] entries = {} #toCacheKey constructor(size, toCacheKey = identity) { this.size = size this.#toCacheKey = toCacheKey } has(key) { return this.#toCacheKey(key) in this.entries } get(key) { if (this.has(key)) { const entry = this.read(key) this.touch(key) return entry } } put(key, entry) { this.write(key, entry) this.touch(key) return entry } clear() { for (const key of Object.keys(this.entries)) { this.evict(key) } } // Private read(key) { return this.entries[this.#toCacheKey(key)] } write(key, entry) { this.entries[this.#toCacheKey(key)] = entry } touch(key) { key = this.#toCacheKey(key) const index = this.keys.indexOf(key) if (index > -1) this.keys.splice(index, 1) this.keys.unshift(key) this.trim() } trim() { for (const key of this.keys.splice(this.size)) { this.evict(key) } } evict(key) { delete this.entries[key] } } ================================================ FILE: src/core/morphing.js ================================================ import { Idiomorph } from "idiomorph" import { FrameElement } from "../elements/frame_element" import { dispatch } from "../util" import { urlsAreEqual } from "./url" /** * Morph the state of the currentElement based on the attributes and contents of * the newElement. Morphing may dispatch turbo:before-morph-element, * turbo:before-morph-attribute, and turbo:morph-element events. * * @param currentElement Element destination of morphing changes * @param newElement Element source of morphing changes */ export function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { Idiomorph.morph(currentElement, newElement, { ...options, callbacks: new DefaultIdiomorphCallbacks(callbacks) }) } /** * Morph the child elements of the currentElement based on the child elements of * the newElement. Morphing children may dispatch turbo:before-morph-element, * turbo:before-morph-attribute, and turbo:morph-element events. * * @param currentElement Element destination of morphing children changes * @param newElement Element source of morphing children changes */ export function morphChildren(currentElement, newElement, options = {}) { morphElements(currentElement, newElement.childNodes, { ...options, morphStyle: "innerHTML" }) } export function shouldRefreshFrameWithMorphing(currentFrame, newFrame) { return currentFrame instanceof FrameElement && currentFrame.shouldReloadWithMorph && (!newFrame || areFramesCompatibleForRefreshing(currentFrame, newFrame)) && !currentFrame.closest("[data-turbo-permanent]") } function areFramesCompatibleForRefreshing(currentFrame, newFrame) { // newFrame cannot yet be an instance of FrameElement because custom // elements don't get initialized until they're attached to the DOM, so // test its Element#nodeName instead return newFrame instanceof Element && newFrame.nodeName === "TURBO-FRAME" && currentFrame.id === newFrame.id && (!newFrame.getAttribute("src") || urlsAreEqual(currentFrame.src, newFrame.getAttribute("src"))) } export function closestFrameReloadableWithMorphing(node) { return node.parentElement.closest("turbo-frame[src][refresh=morph]") } class DefaultIdiomorphCallbacks { #beforeNodeMorphed constructor({ beforeNodeMorphed } = {}) { this.#beforeNodeMorphed = beforeNodeMorphed || (() => true) } beforeNodeAdded = (node) => { return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) } beforeNodeMorphed = (currentElement, newElement) => { if (currentElement instanceof Element) { if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { const event = dispatch("turbo:before-morph-element", { cancelable: true, target: currentElement, detail: { currentElement, newElement } }) return !event.defaultPrevented } else { return false } } } beforeAttributeUpdated = (attributeName, target, mutationType) => { const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } }) return !event.defaultPrevented } beforeNodeRemoved = (node) => { return this.beforeNodeMorphed(node) } afterNodeMorphed = (currentElement, newElement) => { if (currentElement instanceof Element) { dispatch("turbo:morph-element", { target: currentElement, detail: { currentElement, newElement } }) } } } ================================================ FILE: src/core/native/browser_adapter.js ================================================ import { ProgressBar } from "../drive/progress_bar" import { SystemStatusCode } from "../drive/visit" import { uuid, dispatch } from "../../util" import { locationIsVisitable } from "../url" export class BrowserAdapter { progressBar = new ProgressBar() constructor(session) { this.session = session } visitProposedToLocation(location, options) { if (locationIsVisitable(location, this.navigator.rootLocation)) { this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options) } else { window.location.href = location.toString() } } visitStarted(visit) { this.location = visit.location this.redirectedToLocation = null visit.loadCachedSnapshot() visit.issueRequest() } visitRequestStarted(visit) { this.progressBar.setValue(0) if (visit.hasCachedSnapshot() || visit.action != "restore") { this.showVisitProgressBarAfterDelay() } else { this.showProgressBar() } } visitRequestCompleted(visit) { visit.loadResponse() if (visit.response.redirected) { this.redirectedToLocation = visit.redirectedToLocation } } visitRequestFailedWithStatusCode(visit, statusCode) { switch (statusCode) { case SystemStatusCode.networkFailure: case SystemStatusCode.timeoutFailure: case SystemStatusCode.contentTypeMismatch: return this.reload({ reason: "request_failed", context: { statusCode } }) default: return visit.loadResponse() } } visitRequestFinished(_visit) {} visitCompleted(_visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } pageInvalidated(reason) { this.reload(reason) } visitFailed(_visit) { this.progressBar.setValue(1) this.hideVisitProgressBar() } visitRendered(_visit) {} // Link prefetching linkPrefetchingIsEnabledForLocation(location) { return true } // Form Submission Delegate formSubmissionStarted(_formSubmission) { this.progressBar.setValue(0) this.showFormProgressBarAfterDelay() } formSubmissionFinished(_formSubmission) { this.progressBar.setValue(1) this.hideFormProgressBar() } // Private showVisitProgressBarAfterDelay() { this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay) } hideVisitProgressBar() { this.progressBar.hide() if (this.visitProgressBarTimeout != null) { window.clearTimeout(this.visitProgressBarTimeout) delete this.visitProgressBarTimeout } } showFormProgressBarAfterDelay() { if (this.formProgressBarTimeout == null) { this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay) } } hideFormProgressBar() { this.progressBar.hide() if (this.formProgressBarTimeout != null) { window.clearTimeout(this.formProgressBarTimeout) delete this.formProgressBarTimeout } } showProgressBar = () => { this.progressBar.show() } reload(reason) { dispatch("turbo:reload", { detail: reason }) window.location.href = (this.redirectedToLocation || this.location)?.toString() || window.location.href } get navigator() { return this.session.navigator } } ================================================ FILE: src/core/renderer.js ================================================ import { Bardo } from "./bardo" export class Renderer { #activeElement = null static renderElement(currentElement, newElement) { // Abstract method } constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview this.willRender = willRender this.renderElement = this.constructor.renderElement this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) } get shouldRender() { return true } get shouldAutofocus() { 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() { if (this.shouldAutofocus) { const element = this.connectedSnapshot.firstAutofocusableElement if (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) } get renderMethod() { return "replace" } } ================================================ FILE: src/core/session.js ================================================ import { BrowserAdapter } from "./native/browser_adapter" import { CacheObserver } from "../observers/cache_observer" import { FormSubmitObserver } from "../observers/form_submit_observer" import { FrameRedirector } from "./frames/frame_redirector" import { History } from "./drive/history" import { LinkPrefetchObserver } from "../observers/link_prefetch_observer" import { LinkClickObserver } from "../observers/link_click_observer" import { FormLinkClickObserver } from "../observers/form_link_click_observer" import { getAction, expandURL, locationIsVisitable } from "./url" import { Navigator } from "./drive/navigator" import { PageObserver } from "../observers/page_observer" import { ScrollObserver } from "../observers/scroll_observer" import { StreamMessage } from "./streams/stream_message" import { StreamMessageRenderer } from "./streams/stream_message_renderer" import { StreamObserver } from "../observers/stream_observer" import { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markAsBusy, debounce } from "../util" import { PageView } from "./drive/page_view" import { FrameElement } from "../elements/frame_element" import { Preloader } from "./drive/preloader" import { Cache } from "./cache" import { config } from "./config" export class Session { navigator = new Navigator(this) history = new History(this) view = new PageView(this, document.documentElement) adapter = new BrowserAdapter(this) pageObserver = new PageObserver(this) cacheObserver = new CacheObserver() linkPrefetchObserver = new LinkPrefetchObserver(this, document) linkClickObserver = new LinkClickObserver(this, window) formSubmitObserver = new FormSubmitObserver(this, document) scrollObserver = new ScrollObserver(this) streamObserver = new StreamObserver(this) formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) frameRedirector = new FrameRedirector(this, document.documentElement) streamMessageRenderer = new StreamMessageRenderer() cache = new Cache(this) enabled = true started = false #pageRefreshDebouncePeriod = 150 constructor(recentRequests) { this.recentRequests = recentRequests this.preloader = new Preloader(this, this.view.snapshotCache) this.debouncedRefresh = this.refresh this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod } start() { if (!this.started) { this.pageObserver.start() this.cacheObserver.start() this.linkPrefetchObserver.start() this.formLinkClickObserver.start() this.linkClickObserver.start() this.formSubmitObserver.start() this.scrollObserver.start() this.streamObserver.start() this.frameRedirector.start() this.history.start() this.preloader.start() this.started = true this.enabled = true } } disable() { this.enabled = false } stop() { if (this.started) { this.pageObserver.stop() this.cacheObserver.stop() this.linkPrefetchObserver.stop() this.formLinkClickObserver.stop() this.linkClickObserver.stop() this.formSubmitObserver.stop() this.scrollObserver.stop() this.streamObserver.stop() this.frameRedirector.stop() this.history.stop() this.preloader.stop() this.started = false } } registerAdapter(adapter) { this.adapter = adapter } visit(location, options = {}) { const frameElement = options.frame ? document.getElementById(options.frame) : null if (frameElement instanceof FrameElement) { const action = options.action || getVisitAction(frameElement) frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action) frameElement.src = location.toString() } else { this.navigator.proposeVisit(expandURL(location), options) } } refresh(url, options = {}) { options = typeof options === "string" ? { requestId: options } : options const { method, requestId, scroll } = options const isRecentRequest = requestId && this.recentRequests.has(requestId) const isCurrentUrl = url === document.baseURI if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) { this.visit(url, { action: "replace", shouldCacheSnapshot: false, refresh: { method, scroll } }) } } connectStreamSource(source) { this.streamObserver.connectStreamSource(source) } disconnectStreamSource(source) { this.streamObserver.disconnectStreamSource(source) } renderStreamMessage(message) { this.streamMessageRenderer.render(StreamMessage.wrap(message)) } clearCache() { this.view.clearSnapshotCache() } setProgressBarDelay(delay) { console.warn( "Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`" ) this.progressBarDelay = delay } set progressBarDelay(delay) { config.drive.progressBarDelay = delay } get progressBarDelay() { return config.drive.progressBarDelay } set drive(value) { config.drive.enabled = value } get drive() { return config.drive.enabled } set formMode(value) { config.forms.mode = value } get formMode() { return config.forms.mode } get location() { return this.history.location } get restorationIdentifier() { return this.history.restorationIdentifier } get pageRefreshDebouncePeriod() { return this.#pageRefreshDebouncePeriod } set pageRefreshDebouncePeriod(value) { this.refresh = debounce(this.debouncedRefresh.bind(this), value) this.#pageRefreshDebouncePeriod = value } // Preloader delegate shouldPreloadLink(element) { const isUnsafe = element.hasAttribute("data-turbo-method") const isStream = element.hasAttribute("data-turbo-stream") const frameTarget = element.getAttribute("data-turbo-frame") const frame = frameTarget == "_top" ? null : document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])") if (isUnsafe || isStream || frame instanceof FrameElement) { return false } else { const location = new URL(element.href) return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation) } } // History delegate historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) { if (this.enabled) { this.navigator.startVisit(location, restorationIdentifier, { action: "restore", historyChanged: true, direction }) } else { this.adapter.pageInvalidated({ reason: "turbo_disabled" }) } } historyPoppedWithEmptyState(location) { this.history.replace(location) this.view.lastRenderedLocation = location this.view.cacheSnapshot() } // Scroll observer delegate scrollPositionChanged(position) { this.history.updateRestorationData({ scrollPosition: position }) } // Form click observer delegate willSubmitFormLinkToLocation(link, location) { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) } submittedFormLinkToLocation() {} // Link hover observer delegate canPrefetchRequestToLocation(link, location) { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && this.navigator.linkPrefetchingIsEnabledForLocation(location) ) } // Link click observer delegate willFollowLinkToLocation(link, location, event) { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location, event) ) } followedLinkToLocation(link, location) { const action = this.getActionForLink(link) const acceptsStreamResponse = link.hasAttribute("data-turbo-stream") this.visit(location.href, { action, acceptsStreamResponse }) } // Navigator delegate allowsVisitingLocationWithAction(location, action) { return this.applicationAllowsVisitingLocation(location) } visitProposedToLocation(location, options) { extendURLWithDeprecatedProperties(location) this.adapter.visitProposedToLocation(location, options) } // Visit delegate visitStarted(visit) { if (!visit.acceptsStreamResponse) { markAsBusy(document.documentElement) this.view.markVisitDirection(visit.direction) } extendURLWithDeprecatedProperties(visit.location) this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) } visitCompleted(visit) { this.view.unmarkVisitDirection() clearBusyState(document.documentElement) this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) } // Form submit observer delegate willSubmitForm(form, submitter) { const action = getAction(form, submitter) return ( this.submissionIsNavigatable(form, submitter) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation) ) } formSubmitted(form, submitter) { this.navigator.submitForm(form, submitter) } // Page observer delegate pageBecameInteractive() { this.view.lastRenderedLocation = this.location this.notifyApplicationAfterPageLoad() } pageLoaded() { this.history.assumeControlOfScrollRestoration() } pageWillUnload() { this.history.relinquishControlOfScrollRestoration() } // Stream observer delegate receivedMessageFromStream(message) { this.renderStreamMessage(message) } // Page view delegate viewWillCacheSnapshot() { this.notifyApplicationBeforeCachingSnapshot() } allowsImmediateRender({ element }, options) { const event = this.notifyApplicationBeforeRender(element, options) const { defaultPrevented, detail: { render } } = event if (this.view.renderer && render) { this.view.renderer.renderElement = render } return !defaultPrevented } viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location this.notifyApplicationAfterRender(renderMethod) } preloadOnLoadLinksForView(element) { this.preloader.preloadOnLoadLinksForView(element) } viewInvalidated(reason) { this.adapter.pageInvalidated(reason) } // Frame element frameLoaded(frame) { this.notifyApplicationAfterFrameLoad(frame) } frameRendered(fetchResponse, frame) { this.notifyApplicationAfterFrameRender(fetchResponse, frame) } // Application events applicationAllowsFollowingLinkToLocation(link, location, ev) { const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) return !event.defaultPrevented } applicationAllowsVisitingLocation(location) { const event = this.notifyApplicationBeforeVisitingLocation(location) return !event.defaultPrevented } notifyApplicationAfterClickingLinkToLocation(link, location, event) { return dispatch("turbo:click", { target: link, detail: { url: location.href, originalEvent: event }, cancelable: true }) } notifyApplicationBeforeVisitingLocation(location) { return dispatch("turbo:before-visit", { detail: { url: location.href }, cancelable: true }) } notifyApplicationAfterVisitingLocation(location, action) { return dispatch("turbo:visit", { detail: { url: location.href, action } }) } notifyApplicationBeforeCachingSnapshot() { return dispatch("turbo:before-cache") } notifyApplicationBeforeRender(newBody, options) { return dispatch("turbo:before-render", { detail: { newBody, ...options }, cancelable: true }) } notifyApplicationAfterRender(renderMethod) { return dispatch("turbo:render", { detail: { renderMethod } }) } notifyApplicationAfterPageLoad(timing = {}) { return dispatch("turbo:load", { detail: { url: this.location.href, timing } }) } notifyApplicationAfterFrameLoad(frame) { return dispatch("turbo:frame-load", { target: frame }) } notifyApplicationAfterFrameRender(fetchResponse, frame) { return dispatch("turbo:frame-render", { detail: { fetchResponse }, target: frame, cancelable: true }) } // Helpers submissionIsNavigatable(form, submitter) { if (config.forms.mode == "off") { return false } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true if (config.forms.mode == "optin") { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null } else { return submitterIsNavigatable && this.elementIsNavigatable(form) } } } elementIsNavigatable(element) { const container = findClosestRecursively(element, "[data-turbo]") const withinFrame = findClosestRecursively(element, "turbo-frame") // Check if Drive is enabled on the session or we're within a Frame. if (config.drive.enabled || withinFrame) { // Element is navigatable by default, unless `data-turbo="false"`. if (container) { return container.getAttribute("data-turbo") != "false" } else { return true } } else { // Element isn't navigatable by default, unless `data-turbo="true"`. if (container) { return container.getAttribute("data-turbo") == "true" } else { return false } } } // Private getActionForLink(link) { return getVisitAction(link) || "advance" } get snapshot() { return this.view.snapshot } } // Older versions of the Turbo Native adapters referenced the // `Location#absoluteURL` property in their implementations of // the `Adapter#visitProposedToLocation()` and `#visitStarted()` // methods. The Location class has since been removed in favor // of the DOM URL API, and accordingly all Adapter methods now // receive URL objects. // // We alias #absoluteURL to #toString() here to avoid crashing // older adapters which do not expect URL objects. We should // consider removing this support at some point in the future. function extendURLWithDeprecatedProperties(url) { Object.defineProperties(url, deprecatedLocationPropertyDescriptors) } const deprecatedLocationPropertyDescriptors = { absoluteURL: { get() { return this.toString() } } } ================================================ FILE: src/core/snapshot.js ================================================ import { queryAutofocusableElement } from "../util" export class Snapshot { constructor(element) { this.element = element } get activeElement() { return this.element.ownerDocument.activeElement } get children() { return [...this.element.children] } hasAnchor(anchor) { return this.getElementForAnchor(anchor) != null } getElementForAnchor(anchor) { return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null } get isConnected() { return this.element.isConnected } get firstAutofocusableElement() { return queryAutofocusableElement(this.element) } get permanentElements() { return queryPermanentElementsAll(this.element) } getPermanentElementById(id) { return getPermanentElementById(this.element, id) } getPermanentElementMapForSnapshot(snapshot) { const permanentElementMap = {} for (const currentPermanentElement of this.permanentElements) { const { id } = currentPermanentElement const newPermanentElement = snapshot.getPermanentElementById(id) if (newPermanentElement) { permanentElementMap[id] = [currentPermanentElement, newPermanentElement] } } return permanentElementMap } } export function getPermanentElementById(node, id) { return node.querySelector(`#${id}[data-turbo-permanent]`) } export function queryPermanentElementsAll(node) { return node.querySelectorAll("[id][data-turbo-permanent]") } ================================================ FILE: src/core/streams/stream_actions.js ================================================ import { session } from "../" import { morphElements, morphChildren } from "../morphing" export const StreamActions = { after() { this.removeDuplicateTargetSiblings() this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) }, append() { this.removeDuplicateTargetChildren() this.targetElements.forEach((e) => e.append(this.templateContent)) }, before() { this.removeDuplicateTargetSiblings() this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)) }, prepend() { this.removeDuplicateTargetChildren() this.targetElements.forEach((e) => e.prepend(this.templateContent)) }, remove() { this.targetElements.forEach((e) => e.remove()) }, replace() { const method = this.getAttribute("method") this.targetElements.forEach((targetElement) => { if (method === "morph") { morphElements(targetElement, this.templateContent) } else { targetElement.replaceWith(this.templateContent) } }) }, update() { const method = this.getAttribute("method") this.targetElements.forEach((targetElement) => { if (method === "morph") { morphChildren(targetElement, this.templateContent) } else { targetElement.innerHTML = "" targetElement.append(this.templateContent) } }) }, refresh() { const method = this.getAttribute("method") const requestId = this.requestId const scroll = this.getAttribute("scroll") session.refresh(this.baseURI, { method, requestId, scroll }) } } ================================================ FILE: src/core/streams/stream_message.js ================================================ import { activateScriptElement, createDocumentFragment } from "../../util" export class StreamMessage { static contentType = "text/vnd.turbo-stream.html" static wrap(message) { if (typeof message == "string") { return new this(createDocumentFragment(message)) } else { return message } } constructor(fragment) { this.fragment = importStreamElements(fragment) } } function importStreamElements(fragment) { for (const element of fragment.querySelectorAll("turbo-stream")) { const streamElement = document.importNode(element, true) for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)) } element.replaceWith(streamElement) } return fragment } ================================================ FILE: src/core/streams/stream_message_renderer.js ================================================ import { Bardo } from "../bardo" import { getPermanentElementById, queryPermanentElementsAll } from "../snapshot" import { around, elementIsFocusable, nextRepaint, queryAutofocusableElement, uuid } from "../../util" export class StreamMessageRenderer { render({ fragment }) { Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { withAutofocusFromFragment(fragment, () => { withPreservedFocus(() => { document.documentElement.appendChild(fragment) }) }) }) } // Bardo delegate enteringBardo(currentPermanentElement, newPermanentElement) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)) } leavingBardo() {} } function getPermanentElementMapForFragment(fragment) { const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement) const permanentElementMap = {} for (const permanentElementInDocument of permanentElementsInDocument) { const { id } = permanentElementInDocument for (const streamElement of fragment.querySelectorAll("turbo-stream")) { const elementInStream = getPermanentElementById(streamElement.templateElement.content, id) if (elementInStream) { permanentElementMap[id] = [permanentElementInDocument, elementInStream] } } } return permanentElementMap } async function withAutofocusFromFragment(fragment, callback) { const generatedID = `turbo-stream-autofocus-${uuid()}` const turboStreams = fragment.querySelectorAll("turbo-stream") const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams) let willAutofocusId = null if (elementWithAutofocus) { if (elementWithAutofocus.id) { willAutofocusId = elementWithAutofocus.id } else { willAutofocusId = generatedID } elementWithAutofocus.id = willAutofocusId } callback() await nextRepaint() const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body if (hasNoActiveElement && willAutofocusId) { const elementToAutofocus = document.getElementById(willAutofocusId) if (elementIsFocusable(elementToAutofocus)) { elementToAutofocus.focus() } if (elementToAutofocus && elementToAutofocus.id == generatedID) { elementToAutofocus.removeAttribute("id") } } } async function withPreservedFocus(callback) { const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement) const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id if (restoreFocusTo) { const elementToFocus = document.getElementById(restoreFocusTo) if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { elementToFocus.focus() } } } function firstAutofocusableElementInStreams(nodeListOfStreamElements) { for (const streamElement of nodeListOfStreamElements) { const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content) if (elementWithAutofocus) return elementWithAutofocus } return null } ================================================ FILE: src/core/url.js ================================================ import { config } from "./config" export function expandURL(locatable) { return new URL(locatable.toString(), document.baseURI) } export function getAnchor(url) { let anchorMatch if (url.hash) { return url.hash.slice(1) // eslint-disable-next-line no-cond-assign } else if ((anchorMatch = url.href.match(/#(.*)$/))) { return anchorMatch[1] } } export function getAction(form, submitter) { const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action return expandURL(action) } export function getExtension(url) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } export function isPrefixedBy(baseURL, url) { const prefix = addTrailingSlash(url.origin + url.pathname) return addTrailingSlash(baseURL.href) === prefix || baseURL.href.startsWith(prefix) } export function locationIsVisitable(location, rootLocation) { return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location)) } export function getLocationForLink(link) { return expandURL(link.getAttribute("href") || "") } export function getRequestURL(url) { const anchor = getAnchor(url) return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href } export function toCacheKey(url) { return getRequestURL(url) } export function urlsAreEqual(left, right) { return expandURL(left).href == expandURL(right).href } function getPathComponents(url) { return url.pathname.split("/").slice(1) } function getLastPathComponent(url) { return getPathComponents(url).slice(-1)[0] } function addTrailingSlash(value) { return value.endsWith("/") ? value : value + "/" } ================================================ FILE: src/core/view.js ================================================ import { getAnchor } from "./url" export class View { #resolveRenderPromise = (_value) => {} #resolveInterceptionPromise = (_value) => {} constructor(delegate, element) { this.delegate = delegate this.element = element } // Scrolling scrollToAnchor(anchor) { const element = this.snapshot.getElementForAnchor(anchor) if (element) { this.focusElement(element) this.scrollToElement(element) } else { this.scrollToPosition({ x: 0, y: 0 }) } } scrollToAnchorFromLocation(location) { this.scrollToAnchor(getAnchor(location)) } scrollToElement(element) { element.scrollIntoView() } focusElement(element) { if (element instanceof HTMLElement) { if (element.hasAttribute("tabindex")) { element.focus() } else { element.setAttribute("tabindex", "-1") element.focus() element.removeAttribute("tabindex") } } } scrollToPosition({ x, y }) { this.scrollRoot.scrollTo(x, y) } scrollToTop() { this.scrollToPosition({ x: 0, y: 0 }) } get scrollRoot() { return window } // Rendering async render(renderer) { const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer // A workaround to ignore tracked element mismatch reloads when performing // a promoted Visit from a frame navigation const shouldInvalidate = willRender if (shouldRender) { try { 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, renderMethod: this.renderer.renderMethod } const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod) this.delegate.preloadOnLoadLinksForView(this.element) this.finishRenderingSnapshot(renderer) } finally { delete this.renderer this.#resolveRenderPromise(undefined) delete this.renderPromise } } else if (shouldInvalidate) { this.invalidate(renderer.reloadReason) } } invalidate(reason) { this.delegate.viewInvalidated(reason) } async prepareToRenderSnapshot(renderer) { this.markAsPreview(renderer.isPreview) await renderer.prepareToRender() } markAsPreview(isPreview) { if (isPreview) { this.element.setAttribute("data-turbo-preview", "") } else { this.element.removeAttribute("data-turbo-preview") } } markVisitDirection(direction) { this.element.setAttribute("data-turbo-visit-direction", direction) } unmarkVisitDirection() { this.element.removeAttribute("data-turbo-visit-direction") } async renderSnapshot(renderer) { await renderer.render() } finishRenderingSnapshot(renderer) { renderer.finishRendering() } } ================================================ FILE: src/elements/frame_element.js ================================================ export const FrameLoadingStyle = { eager: "eager", lazy: "lazy" } /** * Contains a fragment of HTML which is updated based on navigation within * it (e.g. via links or form submissions). * * @customElement turbo-frame * @example * * * Show all expanded messages in this frame. * * *
* Show response from this form within this frame. *
*
*/ export class FrameElement extends HTMLElement { static delegateConstructor = undefined loaded = Promise.resolve() static get observedAttributes() { return ["disabled", "loading", "src"] } constructor() { super() this.delegate = new FrameElement.delegateConstructor(this) } connectedCallback() { this.delegate.connect() } disconnectedCallback() { this.delegate.disconnect() } reload() { return this.delegate.sourceURLReloaded() } attributeChangedCallback(name) { if (name == "loading") { this.delegate.loadingStyleChanged() } else if (name == "src") { this.delegate.sourceURLChanged() } else if (name == "disabled") { this.delegate.disabledChanged() } } /** * Gets the URL to lazily load source HTML from */ get src() { return this.getAttribute("src") } /** * Sets the URL to lazily load source HTML from */ set src(value) { if (value) { this.setAttribute("src", value) } else { this.removeAttribute("src") } } /** * Gets the refresh mode for the frame. */ get refresh() { return this.getAttribute("refresh") } /** * Sets the refresh mode for the frame. */ set refresh(value) { if (value) { this.setAttribute("refresh", value) } else { this.removeAttribute("refresh") } } get shouldReloadWithMorph() { return this.src && this.refresh === "morph" } /** * Determines if the element is loading */ get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || "") } /** * Sets the value of if the element is loading */ set loading(value) { if (value) { this.setAttribute("loading", value) } else { this.removeAttribute("loading") } } /** * Gets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ get disabled() { return this.hasAttribute("disabled") } /** * Sets the disabled state of the frame. * * If disabled, no requests will be intercepted by the frame. */ set disabled(value) { if (value) { this.setAttribute("disabled", "") } else { this.removeAttribute("disabled") } } /** * Gets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ get autoscroll() { return this.hasAttribute("autoscroll") } /** * Sets the autoscroll state of the frame. * * If true, the frame will be scrolled into view automatically on update. */ set autoscroll(value) { if (value) { this.setAttribute("autoscroll", "") } else { this.removeAttribute("autoscroll") } } /** * Determines if the element has finished loading */ get complete() { return !this.delegate.isLoading } /** * Gets the active state of the frame. * * If inactive, source changes will not be observed. */ get isActive() { return this.ownerDocument === document && !this.isPreview } /** * Sets the active state of the frame. * * If inactive, source changes will not be observed. */ get isPreview() { return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview") } } function frameLoadingStyleFromString(style) { switch (style.toLowerCase()) { case "lazy": return FrameLoadingStyle.lazy default: return FrameLoadingStyle.eager } } ================================================ FILE: src/elements/index.js ================================================ import { FrameController } from "../core/frames/frame_controller" import { FrameElement } from "./frame_element" import { StreamElement } from "./stream_element" import { StreamSourceElement } from "./stream_source_element" FrameElement.delegateConstructor = FrameController export * from "./frame_element" export * from "./stream_element" export * from "./stream_source_element" if (customElements.get("turbo-frame") === undefined) { customElements.define("turbo-frame", FrameElement) } if (customElements.get("turbo-stream") === undefined) { customElements.define("turbo-stream", StreamElement) } if (customElements.get("turbo-stream-source") === undefined) { customElements.define("turbo-stream-source", StreamSourceElement) } ================================================ FILE: src/elements/stream_element.js ================================================ import { StreamActions } from "../core/streams/stream_actions" import { nextRepaint } from "../util" //