diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index e7f884644..e0b937844 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 { clearBusyState, dispatch, markAsBusy } from "../../util" import { StreamMessage } from "../streams/stream_message" export interface FormSubmissionDelegate { @@ -146,6 +146,7 @@ export class FormSubmission { requestStarted(request: FetchRequest) { this.state = FormSubmissionState.waiting + markAsBusy(this.formElement) this.submitter?.setAttribute("disabled", "") dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } }) this.delegate.formSubmissionStarted(this) @@ -181,6 +182,7 @@ export class FormSubmission { requestFinished(request: FetchRequest) { this.state = FormSubmissionState.stopped this.submitter?.removeAttribute("disabled") + clearBusyState(this.formElement) dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }}) this.delegate.formSubmissionFinished(this) } diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts index 59b2ad0e1..fecfb4bf9 100644 --- a/src/core/drive/navigator.ts +++ b/src/core/drive/navigator.ts @@ -1,10 +1,9 @@ -import { Action, isAction } from "../types" +import { Action } from "../types" import { FetchMethod } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { FormSubmission } from "./form_submission" import { expandURL, getAnchor, getRequestURL, Locatable, locationIsVisitable } from "../url" -import { getAttribute } from "../../util" -import { Visit, VisitDelegate, VisitOptions } from "./visit" +import { Visit, VisitDelegate, VisitOptions, getVisitAction } from "./visit" import { PageSnapshot } from "./page_snapshot" export type NavigatorDelegate = VisitDelegate & { @@ -157,9 +156,7 @@ export class Navigator { return this.history.restorationIdentifier } - getActionForFormSubmission(formSubmission: FormSubmission): Action { - const { formElement, submitter } = formSubmission - const action = getAttribute("data-turbo-action", submitter, formElement) - return isAction(action) ? action : "advance" + getActionForFormSubmission({ formElement, submitter }: FormSubmission): Action { + return getVisitAction(submitter, formElement) || "advance" } } diff --git a/src/core/drive/page_renderer.ts b/src/core/drive/page_renderer.ts index a5318673b..2c650227c 100644 --- a/src/core/drive/page_renderer.ts +++ b/src/core/drive/page_renderer.ts @@ -2,6 +2,13 @@ import { Renderer } from "../renderer" import { PageSnapshot } from "./page_snapshot" export class PageRenderer extends Renderer { + private readonly willRender: boolean + + constructor(currentSnapshot: PageSnapshot, newSnapshot: PageSnapshot, isPreview: boolean, willRender = true) { + super(currentSnapshot, newSnapshot, isPreview) + this.willRender = willRender + } + get shouldRender() { return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical } diff --git a/src/core/drive/visit.ts b/src/core/drive/visit.ts index 9e900b89b..858e362e6 100644 --- a/src/core/drive/visit.ts +++ b/src/core/drive/visit.ts @@ -5,8 +5,8 @@ import { History } from "./history" import { getAnchor } from "../url" import { Snapshot } from "../snapshot" import { PageSnapshot } from "./page_snapshot" -import { Action } from "../types" -import { uuid } from "../../util" +import { Action, isAction } from "../types" +import { getAttribute, uuid } from "../../util" import { PageView } from "./page_view" export interface VisitDelegate { @@ -419,6 +419,12 @@ export class Visit implements FetchRequestDelegate { } } +export function getVisitAction(...elements: (Element|undefined)[]): Action | null { + const action = getAttribute("data-turbo-action", ...elements) + + return isAction(action) ? action : null +} + function isSuccessful(statusCode: number) { return statusCode >= 200 && statusCode < 300 } diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index ce98a5fae..6d8bac2b9 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -1,9 +1,8 @@ import { FrameElement, FrameElementDelegate, FrameLoadingStyle } from "../../elements/frame_element" -import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" +import { FrameVisit, FrameVisitDelegate, FrameVisitOptions } from "./frame_visit" import { FetchResponse } from "../../http/fetch_response" import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer" import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy } from "../../util" -import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" import { Snapshot } from "../snapshot" import { ViewDelegate } from "../view" import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" @@ -12,19 +11,17 @@ import { FrameView } from "./frame_view" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" import { FrameRenderer } from "./frame_renderer" import { session } from "../index" -import { isAction } from "../types" +import { Action } from "../types" +import { StreamAction } from "../streams/stream_actions" -export class FrameController implements AppearanceObserverDelegate, FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, FrameElementDelegate, LinkInterceptorDelegate, ViewDelegate> { +export class FrameController implements AppearanceObserverDelegate, FormInterceptorDelegate, FrameElementDelegate, FrameVisitDelegate, LinkInterceptorDelegate, ViewDelegate> { readonly element: FrameElement readonly view: FrameView readonly appearanceObserver: AppearanceObserver readonly linkInterceptor: LinkInterceptor readonly formInterceptor: FormInterceptor currentURL?: string | null - formSubmission?: FormSubmission - fetchResponseLoaded = (fetchResponse: FetchResponse) => {} - private currentFetchRequest: FetchRequest | null = null - private resolveVisitPromise = () => {} + frameVisit?: FrameVisit private connected = false private hasBeenLoaded = false private settingSourceURL = false @@ -61,13 +58,13 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } } sourceURLChanged() { if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } } @@ -76,29 +73,65 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest this.appearanceObserver.start() } else { this.appearanceObserver.stop() - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } } - 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 - } - } + visit(options: Partial = {}) { + const frameVisit = new FrameVisit(this, this.element, options) + frameVisit.start() + } + + submit(options: Partial = {}) { + const { submit } = options + + if (submit) { + const frameVisit = new FrameVisit(this, this.element, options) + frameVisit.start() + } + } + + // Frame visit delegate + + shouldVisit({ isFormSubmission }: FrameVisit) { + return !this.settingSourceURL && this.enabled && this.isActive && (this.reloadable || (this.sourceURL != this.currentURL || isFormSubmission)) + } + + visitStarted(frameVisit: FrameVisit) { + this.frameVisit?.stop() + this.frameVisit = frameVisit + + if (frameVisit.options.url) { + this.currentURL = frameVisit.options.url } + + this.appearanceObserver.stop() + markAsBusy(this.element) + } + + async visitSucceeded({ action, rendering }: FrameVisit, response: FetchResponse) { + await this.loadResponse(response, action, rendering) + } + + async visitFailed({ action, rendering }: FrameVisit, response: FetchResponse) { + await this.loadResponse(response, action, rendering) } - async loadResponse(fetchResponse: FetchResponse) { + visitErrored(frameVisit: FrameVisit, error: Error) { + console.error(error) + this.currentURL = frameVisit.previousURL + this.view.invalidate() + throw error + } + + visitCompleted(frameVisit: FrameVisit) { + clearBusyState(this.element) + this.hasBeenLoaded = true + } + + async loadResponse(fetchResponse: FetchResponse, action: Action | null, rendering: StreamAction) { + const fetchResponseLoaded = this.proposeVisitIfNavigatedWithAction(this.element, action) + if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } @@ -108,25 +141,23 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest 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.view.snapshot, snapshot, false, rendering) if (this.view.renderPromise) await this.view.renderPromise await this.view.render(renderer) session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) - this.fetchResponseLoaded(fetchResponse) + fetchResponseLoaded(fetchResponse) } } catch (error) { console.error(error) this.view.invalidate() - } finally { - this.fetchResponseLoaded = () => {} } } // Appearance observer delegate elementAppearedInViewport(element: Element) { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } // Link interceptor delegate @@ -151,74 +182,9 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest } formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) { - if (this.formSubmission) { - this.formSubmission.stop() - } - - this.reloadable = false - this.formSubmission = new FormSubmission(this, element, submitter) - const { fetchRequest } = this.formSubmission - this.prepareHeadersForRequest(fetchRequest.headers, fetchRequest) - this.formSubmission.start() - } - - // Fetch request delegate - - prepareHeadersForRequest(headers: FetchRequestHeaders, request: FetchRequest) { - headers["Turbo-Frame"] = this.id - } - - requestStarted(request: FetchRequest) { - markAsBusy(this.element) - } - - requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { - this.resolveVisitPromise() - } - - async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { - await this.loadResponse(response) - this.resolveVisitPromise() - } - - requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { - console.error(response) - this.resolveVisitPromise() - } - - requestErrored(request: FetchRequest, error: Error) { - console.error(error) - this.resolveVisitPromise() - } - - requestFinished(request: FetchRequest) { - clearBusyState(this.element) - } - - // Form submission delegate - - formSubmissionStarted({ formElement }: FormSubmission) { - markAsBusy(formElement, this.findFrameElement(formElement)) - } - - formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) { - const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter) - - this.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter) - - frame.delegate.loadResponse(response) - } - - formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { - this.element.delegate.loadResponse(fetchResponse) - } - - formSubmissionErrored(formSubmission: FormSubmission, error: Error) { - console.error(error) - } - - formSubmissionFinished({ formElement }: FormSubmission) { - clearBusyState(formElement, this.findFrameElement(formElement)) + const frame = this.findFrameElement(element, submitter) + frame.removeAttribute("reloadable") + frame.delegate.submit(FrameVisit.optionsForSubmit(element, submitter)) } // View delegate @@ -235,37 +201,16 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest // Private - private async visit(url: 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() - }) - } - private navigateFrame(element: Element, url: string, submitter?: HTMLElement) { const frame = this.findFrameElement(element, submitter) - - this.proposeVisitIfNavigatedWithAction(frame, element, submitter) - frame.setAttribute("reloadable", "") - frame.src = url + frame.delegate.visit(FrameVisit.optionsForClick(element, url)) } - private proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) { - const action = getAttribute("data-turbo-action", submitter, element, frame) - - if (isAction(action)) { + private proposeVisitIfNavigatedWithAction(frame: FrameElement, action: Action | null): (fetchResponse: FetchResponse) => void { + if (action) { const { visitCachedSnapshot } = new SnapshotSubstitution(frame) - frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => { + return (fetchResponse: FetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse const responseHTML = frame.ownerDocument.documentElement.outerHTML @@ -274,6 +219,8 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest session.visit(frame.src, { action, response, visitCachedSnapshot, willRender: false }) } } + } else { + return () => {} } } @@ -381,7 +328,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest } get isLoading() { - return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined + return this.frameVisit !== undefined } get isActive() { diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.ts index c300c43cb..8c25037d4 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.ts @@ -2,6 +2,7 @@ import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor" import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" import { expandURL, getAction, locationIsVisitable } from "../url" +import { FrameVisit } from "./frame_visit" export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptorDelegate { readonly element: Element @@ -31,7 +32,8 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor linkClickIntercepted(element: Element, url: string) { const frame = this.findFrameElement(element) if (frame) { - frame.delegate.linkClickIntercepted(element, url) + frame.setAttribute("reloadable", "") + frame.delegate.visit(FrameVisit.optionsForClick(element, url)) } } @@ -43,7 +45,7 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor const frame = this.findFrameElement(element, submitter) if (frame) { frame.removeAttribute("reloadable") - frame.delegate.formSubmissionIntercepted(element, submitter) + frame.delegate.submit(FrameVisit.optionsForSubmit(element, submitter)) } } diff --git a/src/core/frames/frame_renderer.ts b/src/core/frames/frame_renderer.ts index 398d08a99..d10b3fd86 100644 --- a/src/core/frames/frame_renderer.ts +++ b/src/core/frames/frame_renderer.ts @@ -1,8 +1,16 @@ import { FrameElement } from "../../elements/frame_element" import { nextAnimationFrame } from "../../util" import { Renderer } from "../renderer" +import { Snapshot } from "../snapshot" +import { StreamActions, StreamAction } from "../streams/stream_actions" export class FrameRenderer extends Renderer { + readonly streamAction: StreamAction + constructor(currentSnapshot: Snapshot, newSnapshot: Snapshot, isPreview: boolean, streamAction: StreamAction) { + super(currentSnapshot, newSnapshot, isPreview) + this.streamAction = streamAction + } + get shouldRender() { return true } @@ -20,16 +28,14 @@ export class FrameRenderer extends Renderer { } loadFrameElement() { - const destinationRange = document.createRange() - destinationRange.selectNodeContents(this.currentElement) - destinationRange.deleteContents() + const sourceRange = this.newElement.ownerDocument.createRange() + sourceRange.selectNodeContents(this.newElement) - const frameElement = this.newElement - const sourceRange = frameElement.ownerDocument?.createRange() - if (sourceRange) { - sourceRange.selectNodeContents(frameElement) - this.currentElement.appendChild(sourceRange.extractContents()) - } + StreamActions[this.streamAction].apply({ + removeDuplicates: false, + targetElements: [this.currentElement], + templateContent: sourceRange.extractContents(), + }) } scrollFrameIntoView() { @@ -54,7 +60,7 @@ export class FrameRenderer extends Renderer { get newScriptElements() { return this.currentElement.querySelectorAll("script") - } + } } function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLogicalPosition): ScrollLogicalPosition { diff --git a/src/core/frames/frame_visit.ts b/src/core/frames/frame_visit.ts new file mode 100644 index 000000000..cbdc69947 --- /dev/null +++ b/src/core/frames/frame_visit.ts @@ -0,0 +1,151 @@ +import { expandURL } from "../url" +import { getAttribute } from "../../util" +import { Action } from "../types" +import { getVisitAction } from "../drive/visit" +import { FrameElement } from "../../elements/frame_element" +import { FetchRequest, FetchRequestHeaders, FetchRequestDelegate, FetchMethod } from "../../http/fetch_request" +import { FetchResponse } from "../../http/fetch_response" +import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" +import { StreamAction, streamActionFromString } from "../streams/stream_actions" + +export interface FrameVisitOptions { + action: Action | null, + submit: { form: HTMLFormElement, submitter?: HTMLElement }, + url: string, + rendering: StreamAction | null +} + +export interface FrameVisitDelegate { + shouldVisit(frameVisit: FrameVisit): boolean + visitStarted(frameVisit: FrameVisit): void + visitSucceeded(frameVisit: FrameVisit, response: FetchResponse): void + visitFailed(frameVisit: FrameVisit, response: FetchResponse): void + visitErrored(frameVisit: FrameVisit, error: Error): void + visitCompleted(frameVisit: FrameVisit): void +} + +export class FrameVisit implements FetchRequestDelegate, FormSubmissionDelegate { + readonly delegate: FrameVisitDelegate + readonly element: FrameElement + readonly action: Action | null + readonly previousURL: string | null + readonly rendering: StreamAction + readonly options: Partial + readonly isFormSubmission: boolean = false + + private readonly fetchRequest?: FetchRequest + private readonly formSubmission?: FormSubmission + private resolveVisitPromise = () => {} + + static optionsForClick(element: Element, url: string): Partial { + const action = getVisitAction(element) + const rendering = streamActionFromString(getAttribute("data-turbo-rendering", element)) + + return { action, rendering, url } + } + + static optionsForSubmit(form: HTMLFormElement, submitter?: HTMLElement): Partial { + const action = getVisitAction(form, submitter) + const rendering = streamActionFromString(getAttribute("data-turbo-rendering", submitter, form)) + + return { action, rendering, submit: { form, submitter } } + } + + constructor(delegate: FrameVisitDelegate, element: FrameElement, options: Partial = {}) { + this.delegate = delegate + this.element = element + this.previousURL = this.element.src + const { action, rendering, url, submit } = this.options = options + + this.action = action || getVisitAction(this.element) + this.rendering = rendering || this.element.rendering + if (url) { + this.fetchRequest = new FetchRequest(this, FetchMethod.get, expandURL(url), new URLSearchParams, this.element) + } else if (submit) { + const { fetchRequest } = this.formSubmission = new FormSubmission(this, submit.form, submit.submitter) + this.prepareHeadersForRequest(fetchRequest.headers) + this.isFormSubmission = true + } + } + + async start() { + if (this.delegate.shouldVisit(this)) { + if (this.formSubmission) { + await this.formSubmission.start() + } else { + await this.performRequest() + } + } + } + + stop() { + this.fetchRequest?.cancel() + this.formSubmission?.stop() + } + + // Fetch request delegate + + prepareHeadersForRequest(headers: FetchRequestHeaders) { + headers["Turbo-Frame"] = this.element.id + } + + requestStarted(request: FetchRequest) { + this.delegate.visitStarted(this) + } + + requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { + this.resolveVisitPromise() + } + + requestFinished(request: FetchRequest) { + this.delegate.visitCompleted(this) + } + + async requestSucceededWithResponse(fetchRequest: FetchRequest, fetchResponse: FetchResponse) { + await this.delegate.visitSucceeded(this, fetchResponse) + this.resolveVisitPromise() + } + + async requestFailedWithResponse(request: FetchRequest, fetchResponse: FetchResponse) { + console.error(fetchResponse) + await this.delegate.visitFailed(this, fetchResponse) + this.resolveVisitPromise() + } + + requestErrored(request: FetchRequest, error: Error) { + this.delegate.visitErrored(this, error) + this.resolveVisitPromise() + } + + // Form submission delegate + + formSubmissionStarted({ fetchRequest }: FormSubmission) { + this.requestStarted(fetchRequest) + } + + async formSubmissionSucceededWithResponse({ fetchRequest }: FormSubmission, response: FetchResponse) { + await this.requestSucceededWithResponse(fetchRequest, response) + } + + async formSubmissionFailedWithResponse({ fetchRequest }: FormSubmission, fetchResponse: FetchResponse) { + await this.requestFailedWithResponse(fetchRequest, fetchResponse) + } + + formSubmissionErrored({ fetchRequest }: FormSubmission, error: Error) { + this.requestErrored(fetchRequest, error) + } + + formSubmissionFinished({ fetchRequest }: FormSubmission) { + this.requestFinished(fetchRequest) + } + + private performRequest() { + this.element.loaded = new Promise(resolve => { + this.resolveVisitPromise = () => { + this.resolveVisitPromise = () => {} + resolve() + } + this.fetchRequest?.perform() + }) + } +} diff --git a/src/core/renderer.ts b/src/core/renderer.ts index 7b5def56a..981bd7676 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.ts @@ -10,15 +10,13 @@ export abstract class Renderer = Snapsh readonly currentSnapshot: S readonly newSnapshot: S readonly isPreview: boolean - readonly willRender: boolean readonly promise: Promise private resolvingFunctions?: ResolvingFunctions - constructor(currentSnapshot: S, newSnapshot: S, isPreview: boolean, willRender = true) { + constructor(currentSnapshot: S, newSnapshot: S, isPreview: boolean) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview - this.willRender = willRender this.promise = new Promise((resolve, reject) => this.resolvingFunctions = { resolve, reject }) } diff --git a/src/core/session.ts b/src/core/session.ts index 064e809e6..d44f70c65 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -11,10 +11,10 @@ import { PageObserver, PageObserverDelegate } from "../observers/page_observer" 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 { Action, Position, StreamSource } from "./types" import { clearBusyState, dispatch, markAsBusy } from "../util" import { PageView, PageViewDelegate } from "./drive/page_view" -import { Visit, VisitOptions } from "./drive/visit" +import { Visit, VisitOptions, getVisitAction } from "./drive/visit" import { PageSnapshot } from "./drive/page_snapshot" import { FrameElement } from "../elements/frame_element" import { FetchResponse } from "../http/fetch_response" @@ -344,8 +344,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin // Private getActionForLink(link: Element): Action { - const action = link.getAttribute("data-turbo-action") - return isAction(action) ? action : "advance" + return getVisitAction(link) || "advance" } getTargetFrameForLink(link: Element) { diff --git a/src/core/streams/stream_actions.ts b/src/core/streams/stream_actions.ts index b7ab78e9e..daeea557a 100644 --- a/src/core/streams/stream_actions.ts +++ b/src/core/streams/stream_actions.ts @@ -1,12 +1,41 @@ -import { StreamElement } from "../../elements/stream_element" +export type StreamRenderable = { + targetElements: Element[], + templateContent: DocumentFragment, + removeDuplicates: boolean, +} + +export enum StreamAction { + after = "after", + append = "append", + before = "before", + prepend = "prepend", + remove = "remove", + replace = "replace", + update = "update", + default = update, +} + +export function streamActionFromString(action: string | null): StreamAction | null { + switch (action?.toLowerCase()) { + case "after": return StreamAction.after + case "append": return StreamAction.append + case "before": return StreamAction.before + case "prepend": return StreamAction.prepend + case "remove": return StreamAction.remove + case "replace": return StreamAction.replace + default: return null + } +} -export const StreamActions: { [action: string]: (this: StreamElement) => void } = { +export const StreamActions: { [action: string]: (this: StreamRenderable) => void } = { after() { this.targetElements.forEach(e => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) }, append() { - this.removeDuplicateTargetChildren() + if (this.removeDuplicates) { + removeDuplicateTargetChildren(this) + } this.targetElements.forEach(e => e.append(this.templateContent)) }, @@ -15,7 +44,9 @@ export const StreamActions: { [action: string]: (this: StreamElement) => void } }, prepend() { - this.removeDuplicateTargetChildren() + if (this.removeDuplicates) { + removeDuplicateTargetChildren(this) + } this.targetElements.forEach(e => e.prepend(this.templateContent)) }, @@ -28,9 +59,26 @@ export const StreamActions: { [action: string]: (this: StreamElement) => void } }, update() { - this.targetElements.forEach(e => { + this.targetElements.forEach(e => { e.innerHTML = "" e.append(this.templateContent) }) } } + +/** + * Removes duplicate children (by ID) + */ +const removeDuplicateTargetChildren = (streamRenderable: StreamRenderable) => { + duplicateChildren(streamRenderable).forEach(c => c.remove()) +} + +/** + * Gets the list of duplicate children (i.e. those with the same ID) + */ +const duplicateChildren = ({ targetElements, templateContent }: StreamRenderable) => { + const existingChildren = targetElements.flatMap(e => [...e.children]).filter(c => !!c.id) + const newChildrenIds = [...templateContent?.children].filter(c => !!c.id).map(c => c.id) + + return existingChildren.filter(c => newChildrenIds.includes(c.id)) +} diff --git a/src/elements/frame_element.ts b/src/elements/frame_element.ts index 94f4ac60b..3c66ae66e 100644 --- a/src/elements/frame_element.ts +++ b/src/elements/frame_element.ts @@ -1,4 +1,6 @@ +import { StreamAction, streamActionFromString } from "../core/streams/stream_actions" import { FetchResponse } from "../http/fetch_response" +import { FrameVisitOptions } from "../core/frames/frame_visit" export enum FrameLoadingStyle { eager = "eager", lazy = "lazy" } @@ -8,10 +10,8 @@ export interface FrameElementDelegate { loadingStyleChanged(): void sourceURLChanged(): void disabledChanged(): void - formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement): void - linkClickIntercepted(element: Element, url: string): void - loadResponse(response: FetchResponse): void - fetchResponseLoaded: (fetchResponse: FetchResponse) => void + visit(options: Partial): void + submit(options: Partial): void isLoading: boolean } @@ -70,6 +70,26 @@ export class FrameElement extends HTMLElement { } } + /** + * Gets the action the frame will render its changes with from the `rendering` + * HTML attribute. Defaults to "update" when the attribute is missing. + */ + get rendering(): StreamAction { + return streamActionFromString(this.getAttribute("rendering")) || StreamAction.default + } + + /** + * Sets the action the frame will render its changes with. Defaults to + * "update" when the value is null. + */ + set rendering(value: StreamAction) { + if (value) { + this.setAttribute("rendering", value) + } else { + this.removeAttribute("rendering") + } + } + /** * Gets the URL to lazily load source HTML from */ diff --git a/src/elements/stream_element.ts b/src/elements/stream_element.ts index 6e9b5e8ee..d9ae2426c 100644 --- a/src/elements/stream_element.ts +++ b/src/elements/stream_element.ts @@ -1,4 +1,4 @@ -import { StreamActions } from "../core/streams/stream_actions" +import { StreamActions, StreamRenderable } from "../core/streams/stream_actions" import { nextAnimationFrame } from "../util" // * */ -export class StreamElement extends HTMLElement { +export class StreamElement extends HTMLElement implements StreamRenderable { async connectedCallback() { try { await this.render() @@ -48,24 +48,6 @@ export class StreamElement extends HTMLElement { disconnect() { try { this.remove() } catch {} } - - /** - * Removes duplicate children (by ID) - */ - removeDuplicateTargetChildren() { - this.duplicateChildren.forEach(c => c.remove()) - } - - /** - * Gets the list of duplicate children (i.e. those with the same ID) - */ - get duplicateChildren() { - const existingChildren = this.targetElements.flatMap(e => [...e.children]).filter(c => !!c.id) - const newChildrenIds = [...this.templateContent?.children].filter(c => !!c.id).map(c => c.id) - - return existingChildren.filter(c => newChildrenIds.includes(c.id)) - } - /** * Gets the action function to be performed. @@ -133,6 +115,10 @@ export class StreamElement extends HTMLElement { return this.getAttribute("targets") } + get removeDuplicates() { + return true + } + private raise(message: string): never { throw new Error(`${this.description}: ${message}`) } diff --git a/src/tests/fixtures/frames.html b/src/tests/fixtures/frames.html index 2a84ca2a4..308a56f4e 100644 --- a/src/tests/fixtures/frames.html +++ b/src/tests/fixtures/frames.html @@ -31,8 +31,11 @@

Frames: #frame

Navigate #frame from within Navigate #frame with ?key=value Navigate #frame from within with a[data-turbo-action="advance"] + inside #frame: 500 Navigate #frame to /frames/form.html + outside #frame: 500 + Navigate #frame from outside with a[data-turbo-action="advance"]
diff --git a/src/tests/fixtures/rendering.html b/src/tests/fixtures/rendering.html index 70f653d70..cf37181d3 100644 --- a/src/tests/fixtures/rendering.html +++ b/src/tests/fixtures/rendering.html @@ -43,9 +43,31 @@

Rendering

Rendering
- -
Rendering
-
+
+ +
Rendering
+
+ + Load #frame + Load #frame with data-turbo-rendering="after" + Load #frame with data-turbo-rendering="append" + Load #frame with data-turbo-rendering="before" + Load #frame with data-turbo-rendering="prepend" + Load #frame with data-turbo-rendering="remove" + Load #frame with data-turbo-rendering="replace" + Load #frame with data-turbo-rendering="update" + + + + + + +
+ + + +
+