diff --git a/package.json b/package.json index 03b07fa02..214ddee8e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "idiomorph": "https://github.com/basecamp/idiomorph#rollout-build" + }, "devDependencies": { "@open-wc/testing": "^3.1.7", "@playwright/test": "^1.28.0", diff --git a/src/core/drive/limited_set.js b/src/core/drive/limited_set.js new file mode 100644 index 000000000..a9b5643e7 --- /dev/null +++ b/src/core/drive/limited_set.js @@ -0,0 +1,15 @@ +export class LimitedSet extends Set { + constructor(maxSize) { + super() + this.maxSize = maxSize + } + + add(value) { + if (this.size >= this.maxSize) { + const iterator = this.values() + const oldestValue = iterator.next().value + this.delete(oldestValue) + } + super.add(value) + } +} diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js new file mode 100644 index 000000000..c3379be2b --- /dev/null +++ b/src/core/drive/morph_renderer.js @@ -0,0 +1,97 @@ +import Idiomorph from "idiomorph" +import { dispatch } from "../../util" +import { urlsAreEqual } from "../url" +import { Renderer } from "../renderer" + +export class MorphRenderer extends Renderer { + async render() { + if (this.willRender) await this.#morphBody() + } + + get renderMethod() { + return "morph" + } + + // Private + + async #morphBody() { + this.#morphElements(this.currentElement, this.newElement) + this.#reloadRemoteFrames() + + dispatch("turbo:morph", { + detail: { + currentElement: this.currentElement, + newElement: this.newElement + } + }) + } + + #morphElements(currentElement, newElement, morphStyle = "outerHTML") { + this.isMorphingTurboFrame = this.#remoteFrameReplacement(currentElement, newElement) + + Idiomorph.morph(currentElement, newElement, { + morphStyle: morphStyle, + callbacks: { + beforeNodeAdded: this.#shouldAddElement, + beforeNodeMorphed: this.#shouldMorphElement, + beforeNodeRemoved: this.#shouldRemoveElement + } + }) + } + + #shouldAddElement = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) + } + + #shouldMorphElement = (oldNode, newNode) => { + if (!(oldNode instanceof HTMLElement) || this.isMorphingTurboFrame) { + return true + } + else if (oldNode.hasAttribute("data-turbo-permanent")) { + return false + } else { + return !this.#remoteFrameReplacement(oldNode, newNode) + } + } + + #remoteFrameReplacement = (oldNode, newNode) => { + return this.#isRemoteFrame(oldNode) && this.#isRemoteFrame(newNode) && urlsAreEqual(oldNode.getAttribute("src"), newNode.getAttribute("src")) + } + + #shouldRemoveElement = (node) => { + return this.#shouldMorphElement(node) + } + + #reloadRemoteFrames() { + this.#remoteFrames().forEach((frame) => { + if (this.#isRemoteFrame(frame)) { + this.#renderFrameWithMorph(frame) + frame.reload() + } + }) + } + + #renderFrameWithMorph(frame) { + frame.addEventListener("turbo:before-frame-render", (event) => { + event.detail.render = this.#morphFrameUpdate + }, { once: true }) + } + + #morphFrameUpdate = (currentElement, newElement) => { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }) + this.#morphElements(currentElement, newElement.children, "innerHTML") + } + + #isRemoteFrame(node) { + return node instanceof HTMLElement && node.nodeName.toLowerCase() === "turbo-frame" && node.getAttribute("src") + } + + #remoteFrames() { + return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => { + return !frame.closest('[data-turbo-permanent]') + }) + } +} diff --git a/src/core/drive/navigator.js b/src/core/drive/navigator.js index 00a743b8b..54b01e8ee 100644 --- a/src/core/drive/navigator.js +++ b/src/core/drive/navigator.js @@ -99,7 +99,9 @@ export class Navigator { } else { await this.view.renderPage(snapshot, false, true, this.currentVisit) } - this.view.scrollToTop() + if(!snapshot.shouldPreserveScrollPosition) { + this.view.scrollToTop() + } this.view.clearSnapshotCache() } } diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.js index 8b5a6e9d1..58a5a75a9 100644 --- a/src/core/drive/page_snapshot.js +++ b/src/core/drive/page_snapshot.js @@ -74,6 +74,14 @@ export class PageSnapshot extends Snapshot { return this.headSnapshot.getMetaValue("view-transition") === "same-origin" } + get shouldMorphPage() { + return this.getSetting("refresh-method") === "morph" + } + + get shouldPreserveScrollPosition() { + return this.getSetting("refresh-scroll") === "preserve" + } + // Private getSetting(name) { diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js index 1b95bb1d1..0508d0960 100644 --- a/src/core/drive/page_view.js +++ b/src/core/drive/page_view.js @@ -1,6 +1,7 @@ import { nextEventLoopTick } from "../../util" import { View } from "../view" import { ErrorRenderer } from "./error_renderer" +import { MorphRenderer } from "./morph_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" @@ -15,7 +16,10 @@ export class PageView extends View { } renderPage(snapshot, isPreview = false, willRender = true, visit) { - const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) + const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage + const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer + + const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true @@ -51,6 +55,10 @@ export class PageView extends View { return this.snapshotCache.get(location) } + isPageRefresh(visit) { + return !visit || this.lastRenderedLocation.href === visit.location.href + } + get snapshot() { return PageSnapshot.fromElement(this.element) } diff --git a/src/core/drive/preloader.js b/src/core/drive/preloader.js index 23871a530..ff9d871d6 100644 --- a/src/core/drive/preloader.js +++ b/src/core/drive/preloader.js @@ -1,4 +1,5 @@ import { PageSnapshot } from "./page_snapshot" +import { fetch } from "../../http/fetch" export class Preloader { selector = "a[data-turbo-preload]" diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js index 7fc494c19..4e7796302 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -335,7 +335,7 @@ export class Visit { // Scrolling performScroll() { - if (!this.scrolled && !this.view.forceReloaded) { + if (!this.scrolled && !this.view.forceReloaded && !this.view.snapshot.shouldPreserveScrollPosition) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop() } else { diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 1cab2902e..5940ba761 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -276,7 +276,7 @@ export class FrameController { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, _isPreview) {} + viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element) diff --git a/src/core/index.js b/src/core/index.js index 440d67302..27a2ad15c 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -3,12 +3,11 @@ import { PageRenderer } from "./drive/page_renderer" import { PageSnapshot } from "./drive/page_snapshot" import { FrameRenderer } from "./frames/frame_renderer" import { FormSubmission } from "./drive/form_submission" +import { fetch } from "../http/fetch" const session = new Session() const { cache, navigator } = session -export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer } - -export { StreamActions } from "./streams/stream_actions" +export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer, fetch } /** * Starts the main session. diff --git a/src/core/renderer.js b/src/core/renderer.js index 694a2004c..56e73983e 100644 --- a/src/core/renderer.js +++ b/src/core/renderer.js @@ -79,4 +79,8 @@ export class Renderer { get permanentElementMap() { return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) } + + get renderMethod() { + return "replace" + } } diff --git a/src/core/session.js b/src/core/session.js index 6b3d550f0..928e03aae 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -16,6 +16,7 @@ import { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markA import { PageView } from "./drive/page_view" import { FrameElement } from "../elements/frame_element" import { Preloader } from "./drive/preloader" +import { LimitedSet } from "./drive/limited_set" import { Cache } from "./cache" export class Session { @@ -35,6 +36,7 @@ export class Session { frameRedirector = new FrameRedirector(this, document.documentElement) streamMessageRenderer = new StreamMessageRenderer() cache = new Cache(this) + recentRequests = new LimitedSet(20) drive = true enabled = true @@ -93,6 +95,14 @@ export class Session { } } + refresh(url, requestId) { + const isRecentRequest = requestId && this.recentRequests.has(requestId) + if (!isRecentRequest) { + this.cache.exemptPageFromPreview() + this.visit(url, { action: "replace" }) + } + } + connectStreamSource(source) { this.streamObserver.connectStreamSource(source) } @@ -265,9 +275,9 @@ export class Session { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, isPreview) { + viewRenderedSnapshot(_snapshot, isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location - this.notifyApplicationAfterRender(isPreview) + this.notifyApplicationAfterRender(isPreview, renderMethod) } preloadOnLoadLinksForView(element) { @@ -330,8 +340,8 @@ export class Session { }) } - notifyApplicationAfterRender(isPreview) { - return dispatch("turbo:render", { detail: { isPreview } }) + notifyApplicationAfterRender(isPreview, renderMethod) { + return dispatch("turbo:render", { detail: { isPreview, renderMethod } }) } notifyApplicationAfterPageLoad(timing = {}) { diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 7b06f5b84..064e94ca4 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,3 +1,5 @@ +import { session } from "../" + export const StreamActions = { after() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) @@ -30,5 +32,9 @@ export const StreamActions = { targetElement.innerHTML = "" targetElement.append(this.templateContent) }) + }, + + refresh() { + session.refresh(this.baseURI, this.requestId) } } diff --git a/src/core/view.js b/src/core/view.js index ca81e8bdb..62ec136da 100644 --- a/src/core/view.js +++ b/src/core/view.js @@ -69,7 +69,7 @@ export class View { if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) - this.delegate.viewRenderedSnapshot(snapshot, isPreview) + this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod) this.delegate.preloadOnLoadLinksForView(this.element) this.finishRenderingSnapshot(renderer) } finally { diff --git a/src/elements/frame_element.js b/src/elements/frame_element.js index 0e2bee917..4feb36713 100644 --- a/src/elements/frame_element.js +++ b/src/elements/frame_element.js @@ -75,6 +75,24 @@ export class FrameElement extends HTMLElement { } } + /** + * Gets the refresh mode for the frame. + */ + get refresh() { + return this.getAttribute("refresh") + } + + /** + * Sets the refresh mode for the frame. + */ + set refresh(value) { + if (value) { + this.setAttribute("refresh", value) + } else { + this.removeAttribute("refresh") + } + } + /** * Determines if the element is loading */ diff --git a/src/elements/stream_element.js b/src/elements/stream_element.js index f73084cc2..b9bcf35f0 100644 --- a/src/elements/stream_element.js +++ b/src/elements/stream_element.js @@ -143,6 +143,13 @@ export class StreamElement extends HTMLElement { return this.getAttribute("targets") } + /** + * Reads the request-id attribute + */ + get requestId() { + return this.getAttribute("request-id") + } + #raise(message) { throw new Error(`${this.description}: ${message}`) } diff --git a/src/http/fetch.js b/src/http/fetch.js new file mode 100644 index 000000000..c82ea081e --- /dev/null +++ b/src/http/fetch.js @@ -0,0 +1,13 @@ +import { uuid } from "../util" + +export function fetch(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}) + const requestUID = uuid() + window.Turbo.session.recentRequests.add(requestUID) + modifiedHeaders.append("X-Turbo-Request-Id", requestUID) + + return window.fetch(url, { + ...options, + headers: modifiedHeaders + }) +} diff --git a/src/http/fetch_request.js b/src/http/fetch_request.js index 9b61b8b49..3d79f24ae 100644 --- a/src/http/fetch_request.js +++ b/src/http/fetch_request.js @@ -1,6 +1,7 @@ import { FetchResponse } from "./fetch_response" import { expandURL } from "../core/url" import { dispatch } from "../util" +import { fetch } from "./fetch" export function fetchMethodFromString(method) { switch (method.toLowerCase()) { diff --git a/src/index.js b/src/index.js index 07e3e097d..c3f27878e 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import * as Turbo from "./core" window.Turbo = Turbo Turbo.start() +export { StreamActions } from "./core/streams/stream_actions" export * from "./core" export * from "./elements" export * from "./http" diff --git a/src/tests/fixtures/frame_refresh_morph.html b/src/tests/fixtures/frame_refresh_morph.html new file mode 100644 index 000000000..70bf85601 --- /dev/null +++ b/src/tests/fixtures/frame_refresh_morph.html @@ -0,0 +1,3 @@ + +

Loaded morphed frame

+
diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html new file mode 100644 index 000000000..ec28092a4 --- /dev/null +++ b/src/tests/fixtures/page_refresh.html @@ -0,0 +1,62 @@ + + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be morphed

+
+ +
+ Preserve me! + + +

Frame to be preserved

+
+
+ +
+

Element with Stimulus controller

+
+ +

Link to another page

+ +
+ + + + +
+ +
+
+ + +
+
+ +
+
+ + diff --git a/src/tests/fixtures/page_refresh_replace.html b/src/tests/fixtures/page_refresh_replace.html new file mode 100644 index 000000000..b46a1c327 --- /dev/null +++ b/src/tests/fixtures/page_refresh_replace.html @@ -0,0 +1,41 @@ + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be morphed

+
+ +
+ Preserve me! +
+ +
+ + + + +
+ + diff --git a/src/tests/fixtures/page_refresh_scroll_reset.html b/src/tests/fixtures/page_refresh_scroll_reset.html new file mode 100644 index 000000000..2c0ed1e32 --- /dev/null +++ b/src/tests/fixtures/page_refresh_scroll_reset.html @@ -0,0 +1,44 @@ + + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be refreshed

+
+ +
+ + + + +
+ + diff --git a/src/tests/fixtures/page_refresh_stream_action.html b/src/tests/fixtures/page_refresh_stream_action.html new file mode 100644 index 000000000..4fb80d1c8 --- /dev/null +++ b/src/tests/fixtures/page_refresh_stream_action.html @@ -0,0 +1,19 @@ + + + + + Turbo Streams + + + + +
+ + +
+ +
+ Hello +
+ + diff --git a/src/tests/fixtures/page_refreshed.html b/src/tests/fixtures/page_refreshed.html new file mode 100644 index 000000000..e8c97f923 --- /dev/null +++ b/src/tests/fixtures/page_refreshed.html @@ -0,0 +1,12 @@ + + + + + Turbo + + + + +

Refreshed page

+ + diff --git a/src/tests/fixtures/remote_permanent_frame.html b/src/tests/fixtures/remote_permanent_frame.html new file mode 100644 index 000000000..a1be6c573 --- /dev/null +++ b/src/tests/fixtures/remote_permanent_frame.html @@ -0,0 +1,3 @@ + +

Loaded permanent frame

+
diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index 9f71b536e..b461c971f 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -83,6 +83,7 @@ "turbo:frame-load", "turbo:frame-render", "turbo:frame-missing", + "turbo:before-frame-morph", "turbo:reload" ]) diff --git a/src/tests/functional/page_refresh_stream_action_tests.js b/src/tests/functional/page_refresh_stream_action_tests.js new file mode 100644 index 000000000..cbdeb272a --- /dev/null +++ b/src/tests/functional/page_refresh_stream_action_tests.js @@ -0,0 +1,55 @@ +import { test } from "@playwright/test" +import { assert } from "chai" +import { nextBeat } from "../helpers/page" + +test.beforeEach(async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh_stream_action.html") +}) + +test("test refreshing the page", async ({ page }) => { + assert.match(await textContent(page), /Hello/) + + await page.locator("#content").evaluate((content)=>content.innerHTML = "") + assert.notMatch(await textContent(page), /Hello/) + + await page.click("#refresh button") + await nextBeat() + + assert.match(await textContent(page), /Hello/) +}) + +test("don't refresh the page on self-originated request ids", async ({ page }) => { + assert.match(await textContent(page), /Hello/) + + await page.locator("#content").evaluate((content)=>content.innerHTML = "") + page.evaluate(()=> { window.Turbo.session.recentRequests.add("123") }) + + await page.locator("#request-id").evaluate((input)=>input.value = "123") + await page.click("#refresh button") + await nextBeat() + + assert.notMatch(await textContent(page), /Hello/) +}) + +test("fetch injects a Turbo-Request-Id with a UID generated automatically", async ({ page }) => { + const response1 = await fetchRequestId(page) + const response2 = await fetchRequestId(page) + + assert.notEqual(response1, response2) + + for (const response of [response1, response2]) { + assert.match(response, /.+-.+-.+-.+/) + } +}) + +async function textContent(page) { + const messages = await page.locator("#content") + return await messages.textContent() +} + +async function fetchRequestId(page) { + return await page.evaluate(async () => { + const response = await window.Turbo.fetch("/__turbo/request_id_header") + return response.text() + }) +} diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js new file mode 100644 index 000000000..eb02128d0 --- /dev/null +++ b/src/tests/functional/page_refresh_tests.js @@ -0,0 +1,154 @@ +import { test, expect } from "@playwright/test" +import { assert } from "chai" +import { + hasSelector, + nextBeat, + nextBody, + nextEventNamed, + nextEventOnTarget, + noNextEventOnTarget, + noNextEventNamed +} from "../helpers/page" + +test("renders a page refresh with morphing", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) +}) + +test("doesn't morph when the turbo-refresh-method meta tag is not 'morph'", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh_replace.html") + + await page.click("#form-submit") + expect(await noNextEventNamed(page, "turbo:render", { renderMethod: "morph" })).toBeTruthy() +}) + +test("doesn't morph when the navigation doesn't go to the same URL", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#link") + await expect(page.locator("h1")).toHaveText("One") + + expect(await noNextEventNamed(page, "turbo:render", { renderMethod: "morph" })).toBeTruthy() +}) + +test("uses morphing to update remote frames", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + await nextBeat() + + // Only the frame marked with refresh="morph" uses morphing + expect(await nextEventOnTarget(page, "remote-frame", "turbo:before-frame-morph")).toBeTruthy() + await expect(page.locator("#remote-frame")).toHaveText("Loaded morphed frame") +}) + +test("don't refresh frames contained in [data-turbo-permanent] elements", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + await nextBeat() + + // Only the frame marked with refresh="morph" uses morphing + expect(await noNextEventOnTarget(page, "refresh-reload", "turbo:before-frame-morph")).toBeTruthy() +}) + +test("remote frames are excluded from full page morphing", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.evaluate(() => document.getElementById("remote-frame").setAttribute("data-modified", "true")) + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + await nextBeat() + + await expect(page.locator("#remote-frame")).toHaveAttribute("data-modified", "true") + await expect(page.locator("#remote-frame")).toHaveText("Loaded morphed frame") +}) + +test("it preserves the scroll position when the turbo-refresh-scroll meta tag is 'preserve'", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.evaluate(() => window.scrollTo(10, 10)) + await assertPageScroll(page, 10, 10) + + // not using page.locator("#form-submit").click() because it can reset the scroll position + await page.evaluate(() => document.getElementById("form-submit")?.click()) + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await assertPageScroll(page, 10, 10) +}) + +test("it resets the scroll position when the turbo-refresh-scroll meta tag is 'reset'", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh_scroll_reset.html") + + await page.evaluate(() => window.scrollTo(10, 10)) + await assertPageScroll(page, 10, 10) + + // not using page.locator("#form-submit").click() because it can reset the scroll position + await page.evaluate(() => document.getElementById("form-submit")?.click()) + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await assertPageScroll(page, 0, 0) +}) + +test("it preserves data-turbo-permanent elements", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.evaluate(() => { + const element = document.getElementById("preserve-me") + element.textContent = "Preserve me, I have a family!" + }) + + await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!") +}) + +test("it preserves data-turbo-permanent elements that don't match when their ids do", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.evaluate(() => { + const element = document.getElementById("preserve-me") + + element.textContent = "Preserve me, I have a family!" + document.getElementById("container").append(element) + }) + + await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!") +}) + +test("renders unprocessable entity responses with morphing", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#reject form.unprocessable_entity input[type=submit]") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + await nextBody(page) + + const title = await page.locator("h1") + assert.equal(await title.textContent(), "Unprocessable Entity", "renders the response HTML") + assert.notOk(await hasSelector(page, "#frame form.reject"), "replaces entire page") +}) + +async function assertPageScroll(page, top, left) { + const [scrollTop, scrollLeft] = await page.evaluate(() => { + return [ + document.documentElement.scrollTop || document.body.scrollTop, + document.documentElement.scrollLeft || document.body.scrollLeft + ] + }) + + expect(scrollTop).toEqual(top) + expect(scrollLeft).toEqual(left) +} diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js index 580327d25..ccc75e654 100644 --- a/src/tests/helpers/page.js +++ b/src/tests/helpers/page.js @@ -68,11 +68,13 @@ export function nextBody(_page, timeout = 500) { return sleep(timeout) } -export async function nextEventNamed(page, eventName) { +export async function nextEventNamed(page, eventName, expectedDetail = {}) { let record while (!record) { const records = await readEventLogs(page, 1) - record = records.find(([name]) => name == eventName) + record = records.find(([name, detail]) => { + return name == eventName && Object.entries(expectedDetail).every(([key, value]) => detail[key] === value) + }) } return record[1] } @@ -126,9 +128,11 @@ export async function noNextAttributeMutationNamed(page, elementId, attributeNam return !records.some(([name, _, target]) => name == attributeName && target == elementId) } -export async function noNextEventNamed(page, eventName) { - const records = await readEventLogs(page, 1) - return !records.some(([name]) => name == eventName) +export async function noNextEventNamed(page, eventName, expectedDetail = {}) { + const records = await readEventLogs(page) + return !records.some(([name, detail]) => { + return name === eventName && Object.entries(expectedDetail).every(([key, value]) => value === detail[key]) + }) } export async function noNextEventOnTarget(page, elementId, eventName) { diff --git a/src/tests/server.mjs b/src/tests/server.mjs index 0628f2e30..0d35cb57d 100644 --- a/src/tests/server.mjs +++ b/src/tests/server.mjs @@ -4,7 +4,7 @@ import bodyParser from "body-parser" import multer from "multer" import path from "path" import url from "url" -import { fileURLToPath } from 'url' +import { fileURLToPath } from "url" import fs from "fs" const __filename = fileURLToPath(import.meta.url) @@ -51,6 +51,11 @@ router.get("/redirect", (request, response) => { response.redirect(301, url.format({ pathname, query })) }) +router.post("/refresh", (request, response) => { + const { sleep } = request.body + setTimeout(() => response.redirect("back"), parseInt(sleep || "0", 10)) +}) + router.post("/reject/tall", (request, response) => { const { status } = request.body const fixture = path.join(__dirname, `../../src/tests/fixtures/422_tall.html`) @@ -94,6 +99,28 @@ router.post("/messages", (request, response) => { } }) +router.post("/refreshes", (request, response) => { + const params = { ...request.body, ...request.query } + const { requestId } = params + + if(acceptsStreams(request)){ + response.type("text/vnd.turbo-stream.html; charset=utf-8") + response.send(renderPageRefresh(requestId)) + } else { + response.sendStatus(201) + } +}) + +router.get("/request_id_header", (request, response) => { + const turboRequestHeader = request.get("X-Turbo-Request-Id") + + if (turboRequestHeader) { + response.send(turboRequestHeader); + } else { + response.status(404).send("X-Turbo-Request header not found") + } +}) + router.post("/notfound", (request, response) => { response.type("html").status(404).send("

Not found

") }) @@ -166,6 +193,12 @@ function renderMessageForTargets(content, id, targets) { ` } +function renderPageRefresh(requestId) { + return ` + + ` +} + function acceptsStreams(request) { return !!request.accepts("text/vnd.turbo-stream.html") } diff --git a/src/tests/unit/limited_set_tests.js b/src/tests/unit/limited_set_tests.js new file mode 100644 index 000000000..6fa67ba13 --- /dev/null +++ b/src/tests/unit/limited_set_tests.js @@ -0,0 +1,17 @@ +import { assert } from "@open-wc/testing" +import { LimitedSet } from "../../core/drive/limited_set" + +test("add a limited number of elements", () => { + const set = new LimitedSet(3) + set.add(1) + set.add(2) + set.add(3) + set.add(4) + + assert.equal(set.size, 3) + + assert.notInclude(set, 1) + assert.include(set, 2) + assert.include(set, 3) + assert.include(set, 4) +}) diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index c84731c84..8ca8c48f3 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -2,6 +2,8 @@ import { StreamElement } from "../../elements" import { nextAnimationFrame } from "../../util" import { DOMTestCase } from "../helpers/dom_test_case" import { assert } from "@open-wc/testing" +import { nextBeat } from "../helpers/page" +import * as Turbo from "../../index" function createStreamElement(action, target, templateElement) { const element = new StreamElement() @@ -167,3 +169,30 @@ test("action=before", async () => { assert.ok(subject.find("h1#before")) assert.isNull(element.parentElement) }) + +test("test action=refresh", async () => { + document.body.setAttribute("data-modified", "") + assert.ok(document.body.hasAttribute("data-modified")) + + const element = createStreamElement("refresh") + subject.append(element) + + await nextBeat() + + assert.notOk(document.body.hasAttribute("data-modified")) +}) + +test("test action=refresh discarded when matching request id", async () => { + Turbo.session.recentRequests.add("123") + + document.body.setAttribute("data-modified", "") + assert.ok(document.body.hasAttribute("data-modified")) + + const element = createStreamElement("refresh") + element.setAttribute("request-id", "123") + subject.append(element) + + await nextBeat() + + assert.ok(document.body.hasAttribute("data-modified")) +}) diff --git a/yarn.lock b/yarn.lock index fa00b7d23..b0e316fa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1865,6 +1865,10 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +"idiomorph@https://github.com/basecamp/idiomorph#rollout-build": + version "0.0.8" + resolved "https://github.com/basecamp/idiomorph#e906820368e4c9c52489a3336b8c3826b1bf6de5" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"