diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efbfaa079..649ffd8f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ jobs: runs-on: ubuntu-latest + steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -18,22 +19,33 @@ jobs: key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - run: yarn install + - 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" - CONTENTS="$(jq '.tunnelOptions.drivers[0].name = "chrome"' < intern.json)" - CONTENTS="$(echo ${CONTENTS} | jq --arg chromever "$CHROMEVER" '.tunnelOptions.drivers[0].version = $chromever')" - echo "${CONTENTS}" > intern.json - cat intern.json + echo "CHROMEVER=${CHROMEVER}" >> $GITHUB_ENV - name: Lint run: yarn lint - - name: Test - run: yarn test + - 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 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v2 + with: + name: playwright-report + path: playwright-report - name: Publish dev build run: .github/scripts/publish-dev-build '${{ secrets.DEV_BUILD_GITHUB_TOKEN }}' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81e372f98..5106f6a85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,16 @@ Once you are done developing the feature or bug fix you have 2 options: 2. Run a local webserver and checkout your changes manually ### Testing -The library is tested by running the test suite (found in: `src/tests/*`) against headless browsers. The browsers are setup in `intern.json` check it out to see the used browser environments. +The library is tested by running the test suite (found in: `src/tests/*`) against headless browsers. The browsers are setup in [intern.json](./intern.json) and [playwright.config.ts](./playwright.config.ts). Check them out to see the used browser environments. + +To override the ChromeDriver version, declare the `CHROMEVER` environment +variable. + +First, install the drivers to test the suite in browsers: + +``bash +yarn playwright install --with-deps +``` The tests are using the compiled version of the library and they are themselves also compiled. To compile the tests and library and watch for changes: @@ -42,10 +51,29 @@ The tests are using the compiled version of the library and they are themselves yarn watch ``` -To run the tests: +To run the unit tests: + +```bash +yarn test:unit +``` + +To run the browser tests: + +```bash +yarn test:browser +``` + +To run the browser suite against a particular browser (one of +`chrome|firefox`), pass the value as the `--project=$BROWSER` flag: + +```bash +yarn test:browser --project=chrome +``` + +To run the browser tests in a "headed" browser, pass the `--headed` flag: ```bash -yarn test +yarn test:browser --project=chrome --headed ``` ### Test files @@ -55,14 +83,23 @@ The html files needed for the tests are stored in: `src/tests/fixtures/` ### Run single test -To focus on single test grep for it: -```javascript -yarn test --grep TEST_CASE_NAME +To focus on single test, pass its file path: + +```bas +yarn test:browser TEST_FILE ``` -Where the `TEST_CASE_NAME` is the name of test you want to run. For example: -```javascript -yarn test --grep 'triggers before-render and render events' +Where the `TEST_FILE` is the name of test you want to run. For example: + +```base +yarn test:browser src/tests/functional/drive_tests.ts +``` + +To execute a particular test, append `:LINE` where `LINE` is the line number of +the call to `test("...")`: + +```bash +yarn test:browser src/tests/functional/drive_tests.ts:11 ``` ### Local webserver diff --git a/intern.json b/intern.json index 60c07e97e..583314227 100644 --- a/intern.json +++ b/intern.json @@ -1,6 +1,5 @@ { "suites": "dist/tests/unit.js", - "functionalSuites": "dist/tests/functional.js", "environments": [ { "browserName": "chrome", diff --git a/package.json b/package.json index 1ae29835a..5a74fcfa5 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,14 @@ "access": "public" }, "devDependencies": { + "@playwright/test": "^1.22.2", "@rollup/plugin-node-resolve": "13.1.3", "@rollup/plugin-typescript": "8.3.1", "@types/multer": "^1.4.5", "@typescript-eslint/eslint-plugin": "^5.20.0", "@typescript-eslint/parser": "^5.20.0", "arg": "^5.0.1", + "chai": "~4.3.4", "eslint": "^8.13.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", @@ -58,10 +60,15 @@ "build:win": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types & rollup -c", "watch": "rollup -wc", "start": "node src/tests/runner.js serveOnly", - "test": "NODE_OPTIONS=--inspect node src/tests/runner.js", - "test:win": "SET NODE_OPTIONS=--inspect & node src/tests/runner.js", + "test": "yarn test:unit && yarn test:browser", + "test:browser": "playwright test", + "test:unit": "NODE_OPTIONS=--inspect node src/tests/runner.js", + "test:unit:win": "SET NODE_OPTIONS=--inspect & node src/tests/runner.js", "prerelease": "yarn build && git --no-pager diff && echo && npm pack --dry-run && echo && read -n 1 -p \"Look OK? Press any key to publish and commit v$npm_package_version\" && echo", "release": "npm publish && git commit -am \"$npm_package_name v$npm_package_version\" && git push", "lint": "eslint . --ext .ts" + }, + "engines": { + "node": ">= 14" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..2e8d2f27a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { type PlaywrightTestConfig, devices } from "@playwright/test" + +const config: PlaywrightTestConfig = { + projects: [ + { + name: "chrome", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + ], + testDir: "./src/tests/functional", + testMatch: /.*_tests\.ts/, + webServer: { + command: "yarn start", + url: "http://localhost:9000/src/tests/fixtures/test.js", + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: "http://localhost:9000/", + }, +} + +export default config diff --git a/rollup.config.js b/rollup.config.js index f5fa09df3..bca5ee055 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -30,28 +30,6 @@ export default [ } }, - { - input: "src/tests/functional/index.ts", - output: [ - { - file: "dist/tests/functional.js", - format: "cjs", - sourcemap: true - } - ], - plugins: [ - resolve(), - typescript() - ], - external: [ - "http", - "intern" - ], - watch: { - include: "src/tests/**" - } - }, - { input: "src/tests/unit/index.ts", output: [ diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index 460c9ba0e..244461352 100644 --- a/src/core/drive/form_submission.ts +++ b/src/core/drive/form_submission.ts @@ -1,7 +1,7 @@ import { FetchRequest, FetchMethod, fetchMethodFromString, FetchRequestHeaders } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { expandURL } from "../url" -import { dispatch } from "../../util" +import { attributeTrue, dispatch } from "../../util" import { StreamMessage } from "../streams/stream_message" export interface FormSubmissionDelegate { @@ -29,6 +29,11 @@ enum FormEnctype { plain = "text/plain", } +export type TurboSubmitStartEvent = CustomEvent<{ formSubmission: FormSubmission }> +export type TurboSubmitEndEvent = CustomEvent< + { formSubmission: FormSubmission } & { [K in keyof FormSubmissionResult]?: FormSubmissionResult[K] } +> + function formEnctypeFromString(encoding: string): FormEnctype { switch (encoding.toLowerCase()) { case FormEnctype.multipart: @@ -153,6 +158,9 @@ export class FormSubmission { if (token) { headers["X-CSRF-Token"] = token } + } + + if (this.requestAcceptsTurboStreamResponse(request)) { headers["Accept"] = [StreamMessage.contentType, headers["Accept"]].join(", ") } } @@ -160,7 +168,7 @@ export class FormSubmission { requestStarted(_request: FetchRequest) { this.state = FormSubmissionState.waiting this.submitter?.setAttribute("disabled", "") - dispatch("turbo:submit-start", { + dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this }, }) @@ -197,16 +205,22 @@ export class FormSubmission { requestFinished(_request: FetchRequest) { this.state = FormSubmissionState.stopped this.submitter?.removeAttribute("disabled") - dispatch("turbo:submit-end", { + dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }, }) this.delegate.formSubmissionFinished(this) } + // Private + requestMustRedirect(request: FetchRequest) { return !request.isIdempotent && this.mustRedirect } + + requestAcceptsTurboStreamResponse(request: FetchRequest) { + return !request.isIdempotent || attributeTrue(this.formElement, "data-turbo-stream") + } } function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData { diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts index cd8603f17..935f9eea7 100644 --- a/src/core/drive/navigator.ts +++ b/src/core/drive/navigator.ts @@ -108,9 +108,9 @@ export class Navigator { if (responseHTML) { const snapshot = PageSnapshot.fromHTMLString(responseHTML) if (fetchResponse.serverError) { - await this.view.renderError(snapshot) + await this.view.renderError(snapshot, this.currentVisit) } else { - await this.view.renderPage(snapshot) + await this.view.renderPage(snapshot, false, true, this.currentVisit) } this.view.scrollToTop() this.view.clearSnapshotCache() diff --git a/src/core/drive/page_view.ts b/src/core/drive/page_view.ts index 19c54dd67..042ea748e 100644 --- a/src/core/drive/page_view.ts +++ b/src/core/drive/page_view.ts @@ -4,6 +4,7 @@ import { ErrorRenderer } from "./error_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" +import { Visit } from "./visit" export interface PageViewDelegate extends ViewDelegate { viewWillCacheSnapshot(): void @@ -16,15 +17,18 @@ export class PageView extends View {} private currentFetchRequest: FetchRequest | null = null private resolveVisitPromise = () => {} private connected = false private hasBeenLoaded = false - private settingSourceURL = false + private ignoredAttributes: Set = new Set() constructor(element: FrameElement) { this.element = element @@ -49,13 +53,13 @@ export class FrameController connect() { if (!this.connected) { this.connected = true - this.reloadable = false if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() + } else { + this.loadSourceURL() } this.linkInterceptor.start() this.formInterceptor.start() - this.sourceURLChanged() } } @@ -75,11 +79,23 @@ export class FrameController } sourceURLChanged() { + if (this.isIgnoringChangesTo("src")) return + + if (this.element.isConnected) { + this.complete = false + } + if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { this.loadSourceURL() } } + completeChanged() { + if (this.isIgnoringChangesTo("complete")) return + + this.loadSourceURL() + } + loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() @@ -89,26 +105,12 @@ export class FrameController } } - async loadSourceURL() { - if ( - !this.settingSourceURL && - this.enabled && - this.isActive && - (this.reloadable || this.sourceURL != this.currentURL) - ) { - const previousURL = this.currentURL - this.currentURL = this.sourceURL - if (this.sourceURL) { - try { - this.element.loaded = this.visit(expandURL(this.sourceURL)) - this.appearanceObserver.stop() - await this.element.loaded - this.hasBeenLoaded = true - } catch (error) { - this.currentURL = previousURL - throw error - } - } + private async loadSourceURL() { + if (this.enabled && this.isActive && !this.complete && this.sourceURL) { + this.element.loaded = this.visit(expandURL(this.sourceURL)) + this.appearanceObserver.stop() + await this.element.loaded + this.hasBeenLoaded = true } } @@ -125,6 +127,7 @@ export class FrameController const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false) if (this.view.renderPromise) await this.view.renderPromise await this.view.render(renderer) + this.complete = true session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) this.fetchResponseLoaded(fetchResponse) @@ -146,7 +149,7 @@ export class FrameController // Link interceptor delegate shouldInterceptLinkClick(element: Element, _url: string) { - if (element.hasAttribute("data-turbo-method")) { + if (element.hasAttribute("data-turbo-method") || attributeTrue(element, "data-turbo-stream")) { return false } else { return this.shouldInterceptNavigation(element) @@ -154,7 +157,6 @@ export class FrameController } linkClickIntercepted(element: Element, url: string) { - this.reloadable = true this.navigateFrame(element, url) } @@ -169,7 +171,6 @@ export class FrameController this.formSubmission.stop() } - this.reloadable = false this.formSubmission = new FormSubmission(this, element, submitter) const { fetchRequest } = this.formSubmission this.prepareHeadersForRequest(fetchRequest.headers, fetchRequest) @@ -272,7 +273,6 @@ export class FrameController this.proposeVisitIfNavigatedWithAction(frame, element, submitter) - frame.setAttribute("reloadable", "") frame.src = url } @@ -308,12 +308,12 @@ export class FrameController const id = CSS.escape(this.id) try { - element = activateElement(container.querySelector(`turbo-frame#${id}`), this.currentURL) + element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL) if (element) { return element } - element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.currentURL) + element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL) if (element) { await element.loaded return await this.extractForeignFrameElement(element) @@ -379,24 +379,9 @@ export class FrameController } set sourceURL(sourceURL: string | undefined) { - this.settingSourceURL = true - this.element.src = sourceURL ?? null - this.currentURL = this.element.src - this.settingSourceURL = false - } - - get reloadable() { - const frame = this.findFrameElement(this.element) - return frame.hasAttribute("reloadable") - } - - set reloadable(value: boolean) { - const frame = this.findFrameElement(this.element) - if (value) { - frame.setAttribute("reloadable", "") - } else { - frame.removeAttribute("reloadable") - } + this.ignoringChangesToAttribute("src", () => { + this.element.src = sourceURL ?? null + }) } get loadingStyle() { @@ -407,6 +392,20 @@ export class FrameController return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined } + get complete() { + return this.element.hasAttribute("complete") + } + + set complete(value: boolean) { + this.ignoringChangesToAttribute("complete", () => { + if (value) { + this.element.setAttribute("complete", "") + } else { + this.element.removeAttribute("complete") + } + }) + } + get isActive() { return this.element.isActive && this.connected } @@ -416,6 +415,16 @@ export class FrameController const root = meta?.content ?? "/" return expandURL(root) } + + private isIgnoringChangesTo(attributeName: FrameElementObservedAttribute): boolean { + return this.ignoredAttributes.has(attributeName) + } + + private ignoringChangesToAttribute(attributeName: FrameElementObservedAttribute, callback: () => void) { + this.ignoredAttributes.add(attributeName) + callback() + this.ignoredAttributes.delete(attributeName) + } } class SnapshotSubstitution { diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.ts index 8e41aea8e..1ddb5a0d5 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.ts @@ -42,7 +42,6 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) { const frame = this.findFrameElement(element, submitter) if (frame) { - frame.removeAttribute("reloadable") frame.delegate.formSubmissionIntercepted(element, submitter) } } diff --git a/src/core/frames/frame_renderer.ts b/src/core/frames/frame_renderer.ts index 8257de744..559c3bd1e 100644 --- a/src/core/frames/frame_renderer.ts +++ b/src/core/frames/frame_renderer.ts @@ -36,9 +36,10 @@ export class FrameRenderer extends Renderer { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end") + const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto") if (element) { - element.scrollIntoView({ block }) + element.scrollIntoView({ block, behavior }) return true } } @@ -64,3 +65,11 @@ function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLog return defaultValue } } + +function readScrollBehavior(value: string | null, defaultValue: ScrollBehavior): ScrollBehavior { + if (value == "auto" || value == "smooth") { + return value + } else { + return defaultValue + } +} diff --git a/src/core/frames/link_interceptor.ts b/src/core/frames/link_interceptor.ts index 53ff4b31b..65ff1066f 100644 --- a/src/core/frames/link_interceptor.ts +++ b/src/core/frames/link_interceptor.ts @@ -1,3 +1,5 @@ +import { TurboClickEvent, TurboBeforeVisitEvent } from "../session" + export interface LinkInterceptorDelegate { shouldInterceptLinkClick(element: Element, url: string): boolean linkClickIntercepted(element: Element, url: string): void @@ -33,7 +35,7 @@ export class LinkInterceptor { } } - linkClicked = ((event: CustomEvent) => { + linkClicked = ((event: TurboClickEvent) => { if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url)) { this.clickEvent.preventDefault() @@ -44,9 +46,9 @@ export class LinkInterceptor { delete this.clickEvent }) - willVisit = () => { + willVisit = ((_event: TurboBeforeVisitEvent) => { delete this.clickEvent - } + }) respondsToEventTarget(target: EventTarget | null) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null diff --git a/src/core/index.ts b/src/core/index.ts index f736483cf..24136d790 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -12,6 +12,21 @@ import { FormSubmission } from "./drive/form_submission" const session = new Session() const { navigator } = session export { navigator, session, PageRenderer, PageSnapshot, FrameRenderer } +export { + TurboBeforeCacheEvent, + TurboBeforeRenderEvent, + TurboBeforeVisitEvent, + TurboClickEvent, + TurboFrameLoadEvent, + TurboFrameRenderEvent, + TurboLoadEvent, + TurboRenderEvent, + TurboVisitEvent, +} from "./session" + +export { TurboSubmitStartEvent, TurboSubmitEndEvent } from "./drive/form_submission" +export { TurboBeforeFetchRequestEvent, TurboBeforeFetchResponseEvent } from "../http/fetch_request" +export { TurboBeforeStreamRenderEvent } from "../elements/stream_element" /** * Starts the main session. diff --git a/src/core/native/browser_adapter.ts b/src/core/native/browser_adapter.ts index 7fc70317f..20792025e 100644 --- a/src/core/native/browser_adapter.ts +++ b/src/core/native/browser_adapter.ts @@ -17,6 +17,7 @@ export class BrowserAdapter implements Adapter { visitProgressBarTimeout?: number formProgressBarTimeout?: number + location?: URL constructor(session: Session) { this.session = session @@ -27,9 +28,9 @@ export class BrowserAdapter implements Adapter { } visitStarted(visit: Visit) { + this.location = visit.location visit.loadCachedSnapshot() visit.issueRequest() - visit.changeHistory() visit.goToSamePageAnchor() } @@ -121,7 +122,10 @@ export class BrowserAdapter implements Adapter { reload(reason: ReloadReason) { dispatch("turbo:reload", { detail: reason }) - window.location.reload() + + if (!this.location) return + + window.location.href = this.location.toString() } get navigator() { diff --git a/src/core/session.ts b/src/core/session.ts index 770112393..4b723577f 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -12,7 +12,7 @@ import { ScrollObserver } from "../observers/scroll_observer" import { StreamMessage } from "./streams/stream_message" import { StreamObserver } from "../observers/stream_observer" import { Action, Position, StreamSource, isAction } from "./types" -import { clearBusyState, dispatch, markAsBusy } from "../util" +import { attributeTrue, clearBusyState, dispatch, markAsBusy } from "../util" import { PageView, PageViewDelegate } from "./drive/page_view" import { Visit, VisitOptions } from "./drive/visit" import { PageSnapshot } from "./drive/page_snapshot" @@ -21,6 +21,15 @@ import { FetchResponse } from "../http/fetch_response" import { Preloader, PreloaderDelegate } from "./drive/preloader" export type TimingData = unknown +export type TurboBeforeCacheEvent = CustomEvent +export type TurboBeforeRenderEvent = CustomEvent<{ newBody: HTMLBodyElement; resume: (value: any) => void }> +export type TurboBeforeVisitEvent = CustomEvent<{ url: string }> +export type TurboClickEvent = CustomEvent<{ url: string; originalEvent: MouseEvent }> +export type TurboFrameLoadEvent = CustomEvent +export type TurboFrameRenderEvent = CustomEvent<{ fetchResponse: FetchResponse }> +export type TurboLoadEvent = CustomEvent<{ url: string; timing: TimingData }> +export type TurboRenderEvent = CustomEvent +export type TurboVisitEvent = CustomEvent<{ url: string; action: Action }> export class Session implements @@ -150,11 +159,11 @@ export class Session // Link click observer delegate - willFollowLinkToLocation(link: Element, location: URL) { + willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent) { return ( this.elementDriveEnabled(link) && locationIsVisitable(location, this.snapshot.rootLocation) && - this.applicationAllowsFollowingLinkToLocation(link, location) + this.applicationAllowsFollowingLinkToLocation(link, location, event) ) } @@ -165,16 +174,20 @@ export class Session convertLinkWithMethodClickToFormSubmission(link: Element) { const linkMethod = link.getAttribute("data-turbo-method") + const useTurboStream = attributeTrue(link, "data-turbo-stream") - if (linkMethod) { + if (linkMethod || useTurboStream) { const form = document.createElement("form") - form.method = linkMethod + form.setAttribute("method", linkMethod || "get") form.action = link.getAttribute("href") || "undefined" form.hidden = true - if (link.hasAttribute("data-turbo-confirm")) { - form.setAttribute("data-turbo-confirm", link.getAttribute("data-turbo-confirm")!) - } + const attributes = ["data-turbo-confirm", "data-turbo-stream"] + attributes.forEach((attribute) => { + if (link.hasAttribute(attribute)) { + form.setAttribute(attribute, link.getAttribute(attribute)!) + } + }) const frame = this.getTargetFrameForLink(link) if (frame) { @@ -296,8 +309,8 @@ export class Session // Application events - applicationAllowsFollowingLinkToLocation(link: Element, location: URL) { - const event = this.notifyApplicationAfterClickingLinkToLocation(link, location) + applicationAllowsFollowingLinkToLocation(link: Element, location: URL, ev: MouseEvent) { + const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev) return !event.defaultPrevented } @@ -306,16 +319,16 @@ export class Session return !event.defaultPrevented } - notifyApplicationAfterClickingLinkToLocation(link: Element, location: URL) { - return dispatch("turbo:click", { + notifyApplicationAfterClickingLinkToLocation(link: Element, location: URL, event: MouseEvent) { + return dispatch("turbo:click", { target: link, - detail: { url: location.href }, + detail: { url: location.href, originalEvent: event }, cancelable: true, }) } notifyApplicationBeforeVisitingLocation(location: URL) { - return dispatch("turbo:before-visit", { + return dispatch("turbo:before-visit", { detail: { url: location.href }, cancelable: true, }) @@ -323,27 +336,27 @@ export class Session notifyApplicationAfterVisitingLocation(location: URL, action: Action) { markAsBusy(document.documentElement) - return dispatch("turbo:visit", { detail: { url: location.href, action } }) + return dispatch("turbo:visit", { detail: { url: location.href, action } }) } notifyApplicationBeforeCachingSnapshot() { - return dispatch("turbo:before-cache") + return dispatch("turbo:before-cache") } notifyApplicationBeforeRender(newBody: HTMLBodyElement, resume: (value: any) => void) { - return dispatch("turbo:before-render", { + return dispatch("turbo:before-render", { detail: { newBody, resume }, cancelable: true, }) } notifyApplicationAfterRender() { - return dispatch("turbo:render") + return dispatch("turbo:render") } notifyApplicationAfterPageLoad(timing: TimingData = {}) { clearBusyState(document.documentElement) - return dispatch("turbo:load", { + return dispatch("turbo:load", { detail: { url: this.location.href, timing }, }) } @@ -358,11 +371,11 @@ export class Session } notifyApplicationAfterFrameLoad(frame: FrameElement) { - return dispatch("turbo:frame-load", { target: frame }) + return dispatch("turbo:frame-load", { target: frame }) } notifyApplicationAfterFrameRender(fetchResponse: FetchResponse, frame: FrameElement) { - return dispatch("turbo:frame-render", { + return dispatch("turbo:frame-render", { detail: { fetchResponse }, target: frame, cancelable: true, diff --git a/src/core/streams/stream_actions.ts b/src/core/streams/stream_actions.ts index 9cf84f646..62667e700 100644 --- a/src/core/streams/stream_actions.ts +++ b/src/core/streams/stream_actions.ts @@ -30,9 +30,6 @@ export const StreamActions: { }, update() { - this.targetElements.forEach((e) => { - e.innerHTML = "" - e.append(this.templateContent) - }) + this.targetElements.forEach((e) => e.replaceChildren(this.templateContent)) }, } diff --git a/src/core/url.ts b/src/core/url.ts index 2aa343276..0e45d8f2b 100644 --- a/src/core/url.ts +++ b/src/core/url.ts @@ -25,7 +25,7 @@ export function getExtension(url: URL) { } export function isHTML(url: URL) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml))$/) + return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) } export function isPrefixedBy(baseURL: URL, url: URL) { diff --git a/src/elements/frame_element.ts b/src/elements/frame_element.ts index d64e552e0..3f40937d7 100644 --- a/src/elements/frame_element.ts +++ b/src/elements/frame_element.ts @@ -5,9 +5,12 @@ export enum FrameLoadingStyle { lazy = "lazy", } +export type FrameElementObservedAttribute = keyof FrameElement & ("disabled" | "complete" | "loading" | "src") + export interface FrameElementDelegate { connect(): void disconnect(): void + completeChanged(): void loadingStyleChanged(): void sourceURLChanged(): void disabledChanged(): void @@ -40,8 +43,8 @@ export class FrameElement extends HTMLElement { loaded: Promise = Promise.resolve() readonly delegate: FrameElementDelegate - static get observedAttributes() { - return ["disabled", "loading", "src"] + static get observedAttributes(): FrameElementObservedAttribute[] { + return ["disabled", "complete", "loading", "src"] } constructor() { @@ -59,6 +62,7 @@ export class FrameElement extends HTMLElement { reload() { const { src } = this + this.removeAttribute("complete") this.src = null this.src = src } @@ -66,6 +70,8 @@ export class FrameElement extends HTMLElement { attributeChangedCallback(name: string) { if (name == "loading") { this.delegate.loadingStyleChanged() + } else if (name == "complete") { + this.delegate.completeChanged() } else if (name == "src") { this.delegate.sourceURLChanged() } else { diff --git a/src/elements/index.ts b/src/elements/index.ts index 34ae5410a..730a3a7b5 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -1,11 +1,21 @@ 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" -customElements.define("turbo-frame", FrameElement) -customElements.define("turbo-stream", StreamElement) +if (customElements.get("turbo-frame") === undefined) { + customElements.define("turbo-frame", FrameElement) +} + +if (customElements.get("turbo-stream") === undefined) { + customElements.define("turbo-stream", StreamElement) +} + +if (customElements.get("turbo-stream-source") === undefined) { + customElements.define("turbo-stream-source", StreamSourceElement) +} diff --git a/src/elements/stream_element.ts b/src/elements/stream_element.ts index 8d3475ccf..4e803a3cf 100644 --- a/src/elements/stream_element.ts +++ b/src/elements/stream_element.ts @@ -1,6 +1,8 @@ import { StreamActions } from "../core/streams/stream_actions" import { nextAnimationFrame } from "../util" +export type TurboBeforeStreamRenderEvent = CustomEvent + //