diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a307a8e68..71fa30909 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,6 +19,7 @@ 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 @@ -29,8 +31,14 @@ jobs: - 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: Publish dev build run: .github/scripts/publish-dev-build '${{ secrets.DEV_BUILD_GITHUB_TOKEN }}' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4e7170a1..5106f6a85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,21 +34,46 @@ 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: ```bash 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 +yarn test:browser --project=chrome +``` + +To run the browser tests in a "headed" browser, pass the `--headed` flag: + +```bash +yarn test:browser --project=chrome --headed ``` ### Test files @@ -58,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/cache.ts b/src/core/cache.ts new file mode 100644 index 000000000..715b7d098 --- /dev/null +++ b/src/core/cache.ts @@ -0,0 +1,30 @@ +import { Session } from "./session" +import { setMetaContent } from "../util" + +export class Cache { + readonly session: Session + + constructor(session: Session) { + this.session = session + } + + clear() { + this.session.clearCache() + } + + resetCacheControl() { + this.setCacheControl("") + } + + exemptPageFromCache() { + this.setCacheControl("no-cache") + } + + exemptPageFromPreview() { + this.setCacheControl("no-preview") + } + + private setCacheControl(value: string) { + setMetaContent("turbo-cache-control", value) + } +} diff --git a/src/core/drive/error_renderer.ts b/src/core/drive/error_renderer.ts index 73b0f9652..bd729c2b9 100644 --- a/src/core/drive/error_renderer.ts +++ b/src/core/drive/error_renderer.ts @@ -2,15 +2,21 @@ import { PageSnapshot } from "./page_snapshot" import { Renderer } from "../renderer" export class ErrorRenderer extends Renderer { + static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { + const { documentElement, body } = document + + documentElement.replaceChild(newElement, body) + } + async render() { this.replaceHeadAndBody() this.activateScriptElements() } replaceHeadAndBody() { - const { documentElement, head, body } = document + const { documentElement, head } = document documentElement.replaceChild(this.newHead, head) - documentElement.replaceChild(this.newElement, body) + this.renderElement(this.currentElement, this.newElement) } activateScriptElements() { diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index 56a1fc525..1fb14af37 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 { attributeTrue, dispatch } from "../../util" +import { dispatch, getMetaContent } 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: @@ -163,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 }, }) @@ -200,7 +205,7 @@ 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 }, }) @@ -214,7 +219,7 @@ export class FormSubmission { } requestAcceptsTurboStreamResponse(request: FetchRequest) { - return !request.isIdempotent || attributeTrue(this.formElement, "data-turbo-stream") + return !request.isIdempotent || this.formElement.hasAttribute("data-turbo-stream") } } @@ -241,11 +246,6 @@ function getCookieValue(cookieName: string | null) { } } -function getMetaContent(name: string) { - const element: HTMLMetaElement | null = document.querySelector(`meta[name="${name}"]`) - return element && element.content -} - function responseSucceededWithoutRedirect(response: FetchResponse) { return response.statusCode == 200 && !response.redirected } diff --git a/src/core/drive/page_renderer.ts b/src/core/drive/page_renderer.ts index 0d320a033..9a1e1a9fe 100644 --- a/src/core/drive/page_renderer.ts +++ b/src/core/drive/page_renderer.ts @@ -3,6 +3,14 @@ import { PageSnapshot } from "./page_snapshot" import { ReloadReason } from "../native/browser_adapter" export class PageRenderer extends Renderer { + static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { + if (document.body && newElement instanceof HTMLBodyElement) { + document.body.replaceWith(newElement) + } else { + document.documentElement.appendChild(newElement) + } + } + get shouldRender() { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } @@ -105,11 +113,7 @@ export class PageRenderer extends Renderer { } assignNewBody() { - if (document.body && this.newElement instanceof HTMLBodyElement) { - document.body.replaceWith(this.newElement) - } else { - document.documentElement.appendChild(this.newElement) - } + this.renderElement(this.currentElement, this.newElement) } get newHeadStylesheetElements() { diff --git a/src/core/drive/page_view.ts b/src/core/drive/page_view.ts index 042ea748e..31cf6a99d 100644 --- a/src/core/drive/page_view.ts +++ b/src/core/drive/page_view.ts @@ -1,24 +1,26 @@ import { nextEventLoopTick } from "../../util" -import { View, ViewDelegate } from "../view" +import { View, ViewDelegate, ViewRenderOptions } from "../view" import { ErrorRenderer } from "./error_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" import { Visit } from "./visit" -export interface PageViewDelegate extends ViewDelegate { +export type PageViewRenderOptions = ViewRenderOptions + +export interface PageViewDelegate extends ViewDelegate { viewWillCacheSnapshot(): void } type PageViewRenderer = PageRenderer | ErrorRenderer -export class PageView extends View { +export class PageView extends View { readonly snapshotCache = new SnapshotCache(10) lastRenderedLocation = new URL(location.href) forceReloaded = false renderPage(snapshot: PageSnapshot, isPreview = false, willRender = true, visit?: Visit) { - const renderer = new PageRenderer(this.snapshot, snapshot, isPreview, willRender) + const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true } else { @@ -29,7 +31,7 @@ export class PageView extends View> + ViewDelegate> { readonly element: FrameElement readonly view: FrameView readonly appearanceObserver: AppearanceObserver + readonly formLinkInterceptor: FormLinkInterceptor readonly linkInterceptor: LinkInterceptor readonly formInterceptor: FormInterceptor formSubmission?: FormSubmission @@ -55,11 +59,13 @@ export class FrameController private frame?: FrameElement private resolveInterceptionPromise = (_value: any) => {} readonly restorationIdentifier: string + private previousFrameElement?: FrameElement constructor(element: FrameElement) { this.element = element this.view = new FrameView(this, this.element) this.appearanceObserver = new AppearanceObserver(this, this.element) + this.formLinkInterceptor = new FormLinkInterceptor(this, this.element) this.linkInterceptor = new LinkInterceptor(this, this.element) this.formInterceptor = new FormInterceptor(this, this.element) this.restorationIdentifier = uuid() @@ -73,6 +79,7 @@ export class FrameController } else { this.loadSourceURL() } + this.formLinkInterceptor.start() this.linkInterceptor.start() this.formInterceptor.start() } @@ -82,6 +89,7 @@ export class FrameController if (this.connected) { this.connected = false this.appearanceObserver.stop() + this.formLinkInterceptor.stop() this.linkInterceptor.stop() this.formInterceptor.stop() } @@ -139,7 +147,14 @@ export class FrameController if (html) { const { body } = parseHTMLDocument(html) const snapshot = new Snapshot(await this.extractForeignFrameElement(body)) - const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false) + const renderer = new FrameRenderer( + this, + this.view.snapshot, + snapshot, + FrameRenderer.renderElement, + false, + false + ) if (this.view.renderPromise) await this.view.renderPromise this.changeHistory() @@ -167,14 +182,21 @@ export class FrameController this.loadSourceURL() } + // Form link interceptor delegate + + shouldInterceptFormLinkClick(link: Element): boolean { + return this.shouldInterceptNavigation(link) + } + + formLinkClickIntercepted(link: Element, form: HTMLFormElement): void { + const frame = this.findFrameElement(link) + if (frame) form.setAttribute("data-turbo-frame", frame.id) + } + // Link interceptor delegate shouldInterceptLinkClick(element: Element, _url: string) { - if (element.hasAttribute("data-turbo-method") || attributeTrue(element, "data-turbo-stream")) { - return false - } else { - return this.shouldInterceptNavigation(element) - } + return this.shouldInterceptNavigation(element) } linkClickIntercepted(element: Element, url: string) { @@ -259,8 +281,22 @@ export class FrameController // View delegate - allowsImmediateRender(_snapshot: Snapshot, _resume: (value: any) => void) { - return true + allowsImmediateRender({ element: newFrame }: Snapshot, options: ViewRenderOptions) { + const event = dispatch("turbo:before-frame-render", { + target: this.element, + detail: { newFrame, ...options }, + cancelable: true, + }) + const { + defaultPrevented, + detail: { render }, + } = event + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render + } + + return !defaultPrevented } viewRenderedSnapshot(_snapshot: Snapshot, _isPreview: boolean) {} @@ -271,6 +307,21 @@ export class FrameController viewInvalidated() {} + // Frame renderer delegate + frameExtracted(element: FrameElement) { + this.previousFrameElement = element + } + + visitCachedSnapshot = ({ element }: Snapshot) => { + const frame = element.querySelector("#" + this.element.id) + + if (frame && this.previousFrameElement) { + frame.replaceChildren(...this.previousFrameElement.children) + } + + delete this.previousFrameElement + } + // Private private async visit(url: URL) { @@ -302,7 +353,8 @@ export class FrameController this.frame = frame if (isAction(this.action)) { - const { visitCachedSnapshot } = new SnapshotSubstitution(frame) + const { visitCachedSnapshot } = frame.delegate + frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse @@ -460,22 +512,6 @@ export class FrameController } } -class SnapshotSubstitution { - private readonly clone: Node - private readonly id: string - - constructor(element: FrameElement) { - this.clone = element.cloneNode(true) - this.id = element.id - } - - visitCachedSnapshot = ({ element }: Snapshot) => { - const { id, clone } = this - - element.querySelector("#" + id)?.replaceWith(clone) - } -} - function getFrameElementById(id: string | null) { if (id != null) { const element = document.getElementById(id) diff --git a/src/core/frames/frame_renderer.ts b/src/core/frames/frame_renderer.ts index 8257de744..cdc855d7a 100644 --- a/src/core/frames/frame_renderer.ts +++ b/src/core/frames/frame_renderer.ts @@ -1,8 +1,40 @@ import { FrameElement } from "../../elements/frame_element" import { nextAnimationFrame } from "../../util" -import { Renderer } from "../renderer" +import { Render, Renderer } from "../renderer" +import { Snapshot } from "../snapshot" + +export interface FrameRendererDelegate { + frameExtracted(element: FrameElement): void +} export class FrameRenderer extends Renderer { + private readonly delegate: FrameRendererDelegate + + static renderElement(currentElement: FrameElement, newElement: FrameElement) { + const destinationRange = document.createRange() + destinationRange.selectNodeContents(currentElement) + destinationRange.deleteContents() + + const frameElement = newElement + const sourceRange = frameElement.ownerDocument?.createRange() + if (sourceRange) { + sourceRange.selectNodeContents(frameElement) + currentElement.appendChild(sourceRange.extractContents()) + } + } + + constructor( + delegate: FrameRendererDelegate, + currentSnapshot: Snapshot, + newSnapshot: Snapshot, + renderElement: Render, + isPreview: boolean, + willRender = true + ) { + super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) + this.delegate = delegate + } + get shouldRender() { return true } @@ -20,25 +52,18 @@ export class FrameRenderer extends Renderer { } loadFrameElement() { - const destinationRange = document.createRange() - destinationRange.selectNodeContents(this.currentElement) - destinationRange.deleteContents() - - const frameElement = this.newElement - const sourceRange = frameElement.ownerDocument?.createRange() - if (sourceRange) { - sourceRange.selectNodeContents(frameElement) - this.currentElement.appendChild(sourceRange.extractContents()) - } + this.delegate.frameExtracted(this.newElement.cloneNode(true)) + this.renderElement(this.currentElement, this.newElement) } scrollFrameIntoView() { if (this.currentElement.autoscroll || this.newElement.autoscroll) { const element = this.currentElement.firstElementChild const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end") + const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto") if (element) { - element.scrollIntoView({ block }) + element.scrollIntoView({ block, behavior }) return true } } @@ -64,3 +89,11 @@ function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLog return defaultValue } } + +function readScrollBehavior(value: string | null, defaultValue: ScrollBehavior): ScrollBehavior { + if (value == "auto" || value == "smooth") { + return value + } else { + return defaultValue + } +} diff --git a/src/core/frames/frame_view.ts b/src/core/frames/frame_view.ts index 7dc29dbef..c54dc454f 100644 --- a/src/core/frames/frame_view.ts +++ b/src/core/frames/frame_view.ts @@ -1,6 +1,8 @@ import { FrameElement } from "../../elements" import { Snapshot } from "../snapshot" -import { View } from "../view" +import { View, ViewRenderOptions } from "../view" + +export type FrameViewRenderOptions = ViewRenderOptions export class FrameView extends View { invalidate() { 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..7c989a6e7 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,5 +1,6 @@ import { Adapter } from "./native/adapter" import { Session } from "./session" +import { Cache } from "./cache" import { Locatable } from "./url" import { StreamMessage } from "./streams/stream_message" import { StreamSource } from "./types" @@ -10,8 +11,24 @@ import { FrameRenderer } from "./frames/frame_renderer" import { FormSubmission } from "./drive/form_submission" const session = new Session() +const cache = new Cache(session) const { navigator } = session -export { navigator, session, PageRenderer, PageSnapshot, FrameRenderer } +export { navigator, session, cache, 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/renderer.ts b/src/core/renderer.ts index 7458c7f61..a097473d5 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.ts @@ -1,26 +1,31 @@ import { Bardo, BardoDelegate } from "./bardo" import { Snapshot } from "./snapshot" import { ReloadReason } from "./native/browser_adapter" +import { getMetaContent } from "../util" type ResolvingFunctions = { resolve(value: T | PromiseLike): void reject(reason?: any): void } +export type Render = (newElement: E, currentElement: E) => void + export abstract class Renderer = Snapshot> implements BardoDelegate { readonly currentSnapshot: S readonly newSnapshot: S readonly isPreview: boolean readonly willRender: boolean readonly promise: Promise + renderElement: Render private resolvingFunctions?: ResolvingFunctions private activeElement: Element | null = null - constructor(currentSnapshot: S, newSnapshot: S, isPreview: boolean, willRender = true) { + constructor(currentSnapshot: S, newSnapshot: S, renderElement: Render, isPreview: boolean, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview this.willRender = willRender + this.renderElement = renderElement this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) } @@ -106,7 +111,7 @@ export abstract class Renderer = Snapsh } get cspNonce() { - return document.head.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") + return getMetaContent("csp-nonce") } } diff --git a/src/core/session.ts b/src/core/session.ts index eacae3f7a..254b4c5c1 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -5,6 +5,7 @@ import { FormSubmitObserver, FormSubmitObserverDelegate } from "../observers/for import { FrameRedirector } from "./frames/frame_redirector" import { History, HistoryDelegate } from "./drive/history" import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer" +import { FormLinkInterceptor, FormLinkInterceptorDelegate } from "../observers/form_link_interceptor" import { getAction, expandURL, locationIsVisitable, Locatable } from "./url" import { Navigator, NavigatorDelegate } from "./drive/navigator" import { PageObserver, PageObserverDelegate } from "../observers/page_observer" @@ -12,20 +13,32 @@ 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 { attributeTrue, clearBusyState, dispatch, markAsBusy } from "../util" -import { PageView, PageViewDelegate } from "./drive/page_view" +import { clearBusyState, dispatch, markAsBusy } from "../util" +import { PageView, PageViewDelegate, PageViewRenderOptions } from "./drive/page_view" import { Visit, VisitOptions } from "./drive/visit" import { PageSnapshot } from "./drive/page_snapshot" import { FrameElement } from "../elements/frame_element" +import { FrameViewRenderOptions } from "./frames/frame_view" import { FetchResponse } from "../http/fetch_response" import { Preloader, PreloaderDelegate } from "./drive/preloader" export type TimingData = unknown +export type TurboBeforeCacheEvent = CustomEvent +export type TurboBeforeRenderEvent = CustomEvent<{ newBody: HTMLBodyElement } & PageViewRenderOptions> +export type TurboBeforeVisitEvent = CustomEvent<{ url: string }> +export type TurboClickEvent = CustomEvent<{ url: string; originalEvent: MouseEvent }> +export type TurboFrameLoadEvent = CustomEvent +export type TurboBeforeFrameRenderEvent = CustomEvent<{ newFrame: FrameElement } & FrameViewRenderOptions> +export type TurboFrameRenderEvent = CustomEvent<{ fetchResponse: FetchResponse }> +export type TurboLoadEvent = CustomEvent<{ url: string; timing: TimingData }> +export type TurboRenderEvent = CustomEvent +export type TurboVisitEvent = CustomEvent<{ url: string; action: Action }> export class Session implements FormSubmitObserverDelegate, HistoryDelegate, + FormLinkInterceptorDelegate, LinkClickObserverDelegate, NavigatorDelegate, PageObserverDelegate, @@ -35,7 +48,7 @@ export class Session readonly navigator = new Navigator(this) readonly history = new History(this) readonly preloader = new Preloader(this) - readonly view = new PageView(this, document.documentElement) + readonly view = new PageView(this, document.documentElement as HTMLBodyElement) adapter: Adapter = new BrowserAdapter(this) readonly pageObserver = new PageObserver(this) @@ -44,7 +57,7 @@ export class Session readonly formSubmitObserver = new FormSubmitObserver(this) readonly scrollObserver = new ScrollObserver(this) readonly streamObserver = new StreamObserver(this) - + readonly formLinkInterceptor = new FormLinkInterceptor(this, document.documentElement) readonly frameRedirector = new FrameRedirector(document.documentElement) drive = true @@ -57,6 +70,7 @@ export class Session if (!this.started) { this.pageObserver.start() this.cacheObserver.start() + this.formLinkInterceptor.start() this.linkClickObserver.start() this.formSubmitObserver.start() this.scrollObserver.start() @@ -77,6 +91,7 @@ export class Session if (this.started) { this.pageObserver.stop() this.cacheObserver.stop() + this.formLinkInterceptor.stop() this.linkClickObserver.stop() this.formSubmitObserver.stop() this.scrollObserver.stop() @@ -148,51 +163,27 @@ export class Session this.history.updateRestorationData({ scrollPosition: position }) } + // Form link interceptor delegate + + shouldInterceptFormLinkClick(_link: Element): boolean { + return true + } + + formLinkClickIntercepted(_link: Element, _form: HTMLFormElement) {} + // 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) ) } followedLinkToLocation(link: Element, location: URL) { const action = this.getActionForLink(link) - this.convertLinkWithMethodClickToFormSubmission(link) || this.visit(location.href, { action }) - } - - convertLinkWithMethodClickToFormSubmission(link: Element) { - const linkMethod = link.getAttribute("data-turbo-method") - const useTurboStream = attributeTrue(link, "data-turbo-stream") - - if (linkMethod || useTurboStream) { - const form = document.createElement("form") - form.setAttribute("method", linkMethod || "get") - form.action = link.getAttribute("href") || "undefined" - form.hidden = true - - 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) { - form.setAttribute("data-turbo-frame", frame) - form.addEventListener("turbo:submit-start", () => form.remove()) - } else { - form.addEventListener("submit", () => form.remove()) - } - - document.body.appendChild(form) - return dispatch("submit", { cancelable: true, target: form }) - } else { - return false - } + this.visit(location.href, { action }) } // Navigator delegate @@ -270,9 +261,18 @@ export class Session } } - allowsImmediateRender({ element }: PageSnapshot, resume: (value: any) => void) { - const event = this.notifyApplicationBeforeRender(element, resume) - return !event.defaultPrevented + allowsImmediateRender({ element }: PageSnapshot, options: PageViewRenderOptions) { + const event = this.notifyApplicationBeforeRender(element, options) + const { + defaultPrevented, + detail: { render }, + } = event + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render + } + + return !defaultPrevented } viewRenderedSnapshot(_snapshot: PageSnapshot, _isPreview: boolean) { @@ -305,8 +305,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 } @@ -315,16 +315,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, }) @@ -332,27 +332,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", { - detail: { newBody, resume }, + notifyApplicationBeforeRender(newBody: HTMLBodyElement, options: PageViewRenderOptions) { + return dispatch("turbo:before-render", { + detail: { newBody, ...options }, cancelable: true, }) } notifyApplicationAfterRender() { - return dispatch("turbo:render") + return dispatch("turbo:render") } notifyApplicationAfterPageLoad(timing: TimingData = {}) { clearBusyState(document.documentElement) - return dispatch("turbo:load", { + return dispatch("turbo:load", { detail: { url: this.location.href, timing }, }) } @@ -367,11 +367,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, @@ -427,19 +427,6 @@ export class Session return isAction(action) ? action : "advance" } - getTargetFrameForLink(link: Element) { - const frame = link.getAttribute("data-turbo-frame") - - if (frame) { - return frame - } else { - const container = link.closest("turbo-frame") - if (container) { - return container.id - } - } - } - get snapshot() { return this.view.snapshot } 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/core/view.ts b/src/core/view.ts index db2774dab..c7f8dfceb 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -1,11 +1,16 @@ import { ReloadReason } from "./native/browser_adapter" -import { Renderer } from "./renderer" +import { Renderer, Render } from "./renderer" import { Snapshot } from "./snapshot" import { Position } from "./types" import { getAnchor } from "./url" -export interface ViewDelegate { - allowsImmediateRender(snapshot: S, resume: (value: any) => void): boolean +export interface ViewRenderOptions { + resume: (value: any) => void + render: Render +} + +export interface ViewDelegate> { + allowsImmediateRender(snapshot: S, options: ViewRenderOptions): boolean preloadOnLoadLinksForView(element: Element): void viewRenderedSnapshot(snapshot: S, isPreview: boolean): void viewInvalidated(reason: ReloadReason): void @@ -15,7 +20,7 @@ export abstract class View< E extends Element, S extends Snapshot = Snapshot, R extends Renderer = Renderer, - D extends ViewDelegate = ViewDelegate + D extends ViewDelegate = ViewDelegate > { readonly delegate: D readonly element: E @@ -85,7 +90,8 @@ export abstract class View< this.prepareToRenderSnapshot(renderer) const renderInterception = new Promise((resolve) => (this.resolveInterceptionPromise = resolve)) - const immediateRender = this.delegate.allowsImmediateRender(snapshot, this.resolveInterceptionPromise) + const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement } + const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) diff --git a/src/elements/frame_element.ts b/src/elements/frame_element.ts index 3f40937d7..7e8dfab5c 100644 --- a/src/elements/frame_element.ts +++ b/src/elements/frame_element.ts @@ -1,4 +1,5 @@ import { FetchResponse } from "../http/fetch_response" +import { Snapshot } from "../core/snapshot" export enum FrameLoadingStyle { eager = "eager", @@ -18,6 +19,7 @@ export interface FrameElementDelegate { linkClickIntercepted(element: Element, url: string): void loadResponse(response: FetchResponse): void fetchResponseLoaded: (fetchResponse: FetchResponse) => void + visitCachedSnapshot: (snapshot: Snapshot) => void isLoading: boolean } 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 + //