Skip to content

Commit

Permalink
Export Type declarations for turbo: events
Browse files Browse the repository at this point in the history
Various `turbo:`-prefixed events are dispatched as [CustomEvent][]
instances with data encoded into the [detail][] property.

In TypeScript, that property is encoded as `any`, but the `CustomEvent`
type is generic (i.e. `CustomEvent<T>`) where the generic Type argument
describes the structure of the `detail` key.

This commit introduces types that extend from `CustomEvent` for each
event, and exports them from `/core/index.ts`, which is exported from
`/index.ts` in-turn.

In practice, there are no changes to the implementation. However,
TypeScript consumers of the package can import the types. At the same
time, the internal implementation can depend on the types to ensure
consistency throughout.

[CustomEvent]: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent
[detail]: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail
  • Loading branch information
seanpdoyle committed Nov 18, 2021
1 parent ca1117b commit 7d3b397
Show file tree
Hide file tree
Showing 10 changed files with 71 additions and 36 deletions.
7 changes: 5 additions & 2 deletions src/core/drive/form_submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ 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: return FormEnctype.multipart
Expand Down Expand Up @@ -146,7 +149,7 @@ export class FormSubmission {
requestStarted(request: FetchRequest) {
this.state = FormSubmissionState.waiting
this.submitter?.setAttribute("disabled", "")
dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } })
dispatch<TurboSubmitStartEvent>("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } })
this.delegate.formSubmissionStarted(this)
}

Expand Down Expand Up @@ -180,7 +183,7 @@ export class FormSubmission {
requestFinished(request: FetchRequest) {
this.state = FormSubmissionState.stopped
this.submitter?.removeAttribute("disabled")
dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }})
dispatch<TurboSubmitEndEvent>("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }})
this.delegate.formSubmissionFinished(this)
}

Expand Down
7 changes: 3 additions & 4 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
import { FrameView } from "./frame_view"
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"
import { FrameRenderer } from "./frame_renderer"
import { session } from "../index"
import { TurboFrameRenderEvent, session } from "../index"
import { isAction } from "../types"

export class FrameController implements AppearanceObserverDelegate, FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, FrameElementDelegate, LinkInterceptorDelegate, ViewDelegate<Snapshot<FrameElement>> {
Expand Down Expand Up @@ -262,16 +262,15 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest

if (isAction(action)) {
const delegate = new SnapshotSubstitution(frame)
const proposeVisit = (event: Event) => {
const { target, detail: { fetchResponse } } = event as CustomEvent
const proposeVisit = <EventListener>(({ target, detail: { fetchResponse } }: TurboFrameRenderEvent) => {
if (target instanceof FrameElement && target.src) {
const { statusCode, redirected } = fetchResponse
const responseHTML = target.ownerDocument.documentElement.outerHTML
const response = { statusCode, redirected, responseHTML }

session.visit(target.src, { action, response, delegate })
}
}
})

frame.addEventListener("turbo:frame-render", proposeVisit , { once: true })
}
Expand Down
8 changes: 5 additions & 3 deletions src/core/frames/link_interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TurboClickEvent, TurboBeforeVisitEvent } from "../session"

export interface LinkInterceptorDelegate {
shouldInterceptLinkClick(element: Element, url: string): boolean
linkClickIntercepted(element: Element, url: string): void
Expand Down Expand Up @@ -33,7 +35,7 @@ export class LinkInterceptor {
}
}

linkClicked = <EventListener>((event: CustomEvent) => {
linkClicked = <EventListener>((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()
Expand All @@ -44,9 +46,9 @@ export class LinkInterceptor {
delete this.clickEvent
})

willVisit = () => {
willVisit = <EventListener>((event: TurboBeforeVisitEvent) => {
delete this.clickEvent
}
})

respondsToEventTarget(target: EventTarget | null) {
const element
Expand Down
15 changes: 15 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ import { FormSubmission } from "./drive/form_submission"
const session = new Session
const { navigator } = session
export { navigator, session, PageRenderer, PageSnapshot }
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.
Expand Down
32 changes: 20 additions & 12 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ import { StreamObserver } from "../observers/stream_observer"
import { Action, Position, StreamSource, isAction } from "./types"
import { clearBusyState, dispatch, markAsBusy } from "../util"
import { PageView, PageViewDelegate } from "./drive/page_view"
import { Visit, VisitOptions } from "./drive/visit"
import { TimingMetrics, Visit, VisitOptions } from "./drive/visit"
import { PageSnapshot } from "./drive/page_snapshot"
import { FrameElement } from "../elements/frame_element"
import { FetchResponse } from "../http/fetch_response"

export type TimingData = {}
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 }>
export type TurboFrameLoadEvent = CustomEvent
export type TurboFrameRenderEvent = CustomEvent<{ fetchResponse: FetchResponse }>
export type TurboLoadEvent = CustomEvent<{ url: string, timing: TimingMetrics }>
export type TurboRenderEvent = CustomEvent
export type TurboVisitEvent = CustomEvent<{ url: string, action: Action }>

export class Session implements FormSubmitObserverDelegate, HistoryDelegate, LinkClickObserverDelegate, NavigatorDelegate, PageObserverDelegate, PageViewDelegate {
readonly navigator = new Navigator(this)
Expand Down Expand Up @@ -280,45 +288,45 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
}

notifyApplicationAfterClickingLinkToLocation(link: Element, location: URL) {
return dispatch("turbo:click", { target: link, detail: { url: location.href }, cancelable: true })
return dispatch<TurboClickEvent>("turbo:click", { detail: { url: location.href }, cancelable: true })
}

notifyApplicationBeforeVisitingLocation(location: URL) {
return dispatch("turbo:before-visit", { detail: { url: location.href }, cancelable: true })
return dispatch<TurboBeforeVisitEvent>("turbo:before-visit", { detail: { url: location.href }, cancelable: true })
}

notifyApplicationAfterVisitingLocation(location: URL, action: Action) {
markAsBusy(document.documentElement)
return dispatch("turbo:visit", { detail: { url: location.href, action } })
return dispatch<TurboVisitEvent>("turbo:visit", { detail: { url: location.href, action } })
}

notifyApplicationBeforeCachingSnapshot() {
return dispatch("turbo:before-cache")
return dispatch<TurboBeforeCacheEvent>("turbo:before-cache")
}

notifyApplicationBeforeRender(newBody: HTMLBodyElement, resume: (value: any) => void) {
return dispatch("turbo:before-render", { detail: { newBody, resume }, cancelable: true })
return dispatch<TurboBeforeRenderEvent>("turbo:before-render", { detail: { newBody, resume }, cancelable: true })
}

notifyApplicationAfterRender() {
return dispatch("turbo:render")
return dispatch<TurboRenderEvent>("turbo:render")
}

notifyApplicationAfterPageLoad(timing: TimingData = {}) {
notifyApplicationAfterPageLoad(timing: TimingMetrics = {}) {
clearBusyState(document.documentElement)
return dispatch("turbo:load", { detail: { url: this.location.href, timing }})
return dispatch<TurboLoadEvent>("turbo:load", { detail: { url: this.location.href, timing }})
}

notifyApplicationAfterVisitingSamePageLocation(oldURL: URL, newURL: URL) {
dispatchEvent(new HashChangeEvent("hashchange", { oldURL: oldURL.toString(), newURL: newURL.toString() }))
}

notifyApplicationAfterFrameLoad(frame: FrameElement) {
return dispatch("turbo:frame-load", { target: frame })
return dispatch<TurboFrameLoadEvent>("turbo:frame-load", { target: frame })
}

notifyApplicationAfterFrameRender(fetchResponse: FetchResponse, frame: FrameElement) {
return dispatch("turbo:frame-render", { detail: { fetchResponse }, target: frame, cancelable: true })
return dispatch<TurboFrameRenderEvent>("turbo:frame-render", { detail: { fetchResponse }, target: frame, cancelable: true })
}

// Helpers
Expand Down
14 changes: 8 additions & 6 deletions src/elements/stream_element.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { StreamActions } from "../core/streams/stream_actions"
import { nextAnimationFrame } from "../util"

export type TurboBeforeStreamRenderEvent = CustomEvent

// <turbo-stream action=replace target=id><template>...

/**
Expand Down Expand Up @@ -48,24 +50,24 @@ 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.
Expand All @@ -85,7 +87,7 @@ export class StreamElement extends HTMLElement {
* Gets the target elements which the template will be rendered to.
*/
get targetElements() {
if (this.target) {
if (this.target) {
return this.targetElementsById
} else if (this.targets) {
return this.targetElementsByQuery
Expand Down Expand Up @@ -141,7 +143,7 @@ export class StreamElement extends HTMLElement {
return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"
}

private get beforeRenderEvent() {
private get beforeRenderEvent(): TurboBeforeStreamRenderEvent {
return new CustomEvent("turbo:before-stream-render", { bubbles: true, cancelable: true })
}

Expand Down
7 changes: 5 additions & 2 deletions src/http/fetch_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { FetchResponse } from "./fetch_response"
import { FrameElement } from "../elements/frame_element"
import { dispatch } from "../util"

export type TurboBeforeFetchRequestEvent = CustomEvent<{ fetchOptions: RequestInit, url: string, resume: (value: any) => void }>
export type TurboBeforeFetchResponseEvent = CustomEvent<{ fetchResponse: FetchResponse }>

export interface FetchRequestDelegate {
referrer?: URL

Expand Down Expand Up @@ -101,7 +104,7 @@ export class FetchRequest {

async receive(response: Response): Promise<FetchResponse> {
const fetchResponse = new FetchResponse(response)
const event = dispatch("turbo:before-fetch-response", { cancelable: true, detail: { fetchResponse }, target: this.target as EventTarget })
const event = dispatch<TurboBeforeFetchResponseEvent>("turbo:before-fetch-response", { cancelable: true, detail: { fetchResponse }, target: this.target as EventTarget })
if (event.defaultPrevented) {
this.delegate.requestPreventedHandlingResponse(this, fetchResponse)
} else if (fetchResponse.succeeded) {
Expand Down Expand Up @@ -140,7 +143,7 @@ export class FetchRequest {

private async allowRequestToBeIntercepted(fetchOptions: RequestInit) {
const requestInterception = new Promise(resolve => this.resolveRequestPromise = resolve)
const event = dispatch("turbo:before-fetch-request", {
const event = dispatch<TurboBeforeFetchRequestEvent>("turbo:before-fetch-request", {
cancelable: true,
detail: {
fetchOptions,
Expand Down
6 changes: 4 additions & 2 deletions src/observers/cache_observer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TurboBeforeCacheEvent } from "../core/session"

export class CacheObserver {
started = false

Expand All @@ -15,11 +17,11 @@ export class CacheObserver {
}
}

removeStaleElements() {
removeStaleElements = <EventListener>((event: TurboBeforeCacheEvent) => {
const staleElements = [ ...document.querySelectorAll('[data-turbo-cache="false"]') ]

for (const element of staleElements) {
element.remove()
}
}
})
}
5 changes: 3 additions & 2 deletions src/observers/stream_observer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TurboBeforeFetchResponseEvent } from "../http/fetch_request"
import { FetchResponse } from "../http/fetch_response"
import { StreamMessage } from "../core/streams/stream_message"
import { StreamSource } from "../core/types"
Expand Down Expand Up @@ -47,7 +48,7 @@ export class StreamObserver {
return this.sources.has(source)
}

inspectFetchResponse = <EventListener>((event: CustomEvent) => {
inspectFetchResponse = <EventListener>((event: TurboBeforeFetchResponseEvent) => {
const response = fetchResponseFromEvent(event)
if (response && fetchResponseIsStream(response)) {
event.preventDefault()
Expand All @@ -73,7 +74,7 @@ export class StreamObserver {
}
}

function fetchResponseFromEvent(event: CustomEvent) {
function fetchResponseFromEvent(event: TurboBeforeFetchResponseEvent) {
const fetchResponse = event.detail?.fetchResponse
if (fetchResponse instanceof FetchResponse) {
return fetchResponse
Expand Down
6 changes: 3 additions & 3 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type DispatchOptions = { target: EventTarget, cancelable: boolean, detail: any }
export type DispatchOptions<T extends CustomEvent> = { target: EventTarget, cancelable: boolean, detail: T["detail"] }

export function dispatch(eventName: string, { target, cancelable, detail }: Partial<DispatchOptions> = {}) {
const event = new CustomEvent(eventName, { cancelable, bubbles: true, detail })
export function dispatch<T extends CustomEvent>(eventName: string, { target, cancelable, detail }: Partial<DispatchOptions<T>> = {}) {
const event = new CustomEvent<T["detail"]>(eventName, { cancelable, bubbles: true, detail })

if (target && (target as Element).isConnected) {
target.dispatchEvent(event);
Expand Down

0 comments on commit 7d3b397

Please sign in to comment.