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..4cc10a6e8 --- /dev/null +++ b/src/core/drive/morph_renderer.js @@ -0,0 +1,88 @@ +import Idiomorph from "idiomorph" +import { nextAnimationFrame } from "../../util" +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() + + this.#dispatchEvent("turbo:morph", { currentElement: this.currentElement, newElement: this.newElement }) + } + + #morphElements(currentElement, newElement, morphStyle = "outerHTML") { + Idiomorph.morph(currentElement, newElement, { + morphStyle: morphStyle, + callbacks: { + beforeNodeMorphed: this.#shouldMorphElement, + beforeNodeRemoved: this.#shouldRemoveElement, + afterNodeMorphed: this.#reloadStimulusControllers + } + }) + } + + #reloadRemoteFrames() { + this.#remoteFrames().forEach((frame) => { + if (this.#isFrameReloadedWithMorph(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) => { + this.#dispatchEvent("turbo:before-frame-morph", { currentElement, newElement }, currentElement) + this.#morphElements(currentElement, newElement, "innerHTML") + } + + #shouldRemoveElement = (node) => { + return this.#shouldMorphElement(node) + } + + #shouldMorphElement = (node) => { + if (node instanceof HTMLElement) { + return !node.hasAttribute("data-turbo-permanent") + } else { + return true + } + } + + #reloadStimulusControllers = async (node) => { + if (node instanceof HTMLElement && node.hasAttribute("data-controller")) { + const originalAttribute = node.getAttribute("data-controller") + node.removeAttribute("data-controller") + await nextAnimationFrame() + node.setAttribute("data-controller", originalAttribute) + } + } + + #isFrameReloadedWithMorph(element) { + return element.getAttribute("src") && element.getAttribute("refresh") === "morph" + } + + #remoteFrames() { + return document.querySelectorAll("turbo-frame[src]") + } + + #dispatchEvent(name, detail, target = document.documentElement) { + const event = new CustomEvent(name, { bubbles: true, cancelable: true, detail }) + target.dispatchEvent(event) + return event + } +} diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.js index 47488d785..b7fcb711e 100644 --- a/src/core/drive/page_snapshot.js +++ b/src/core/drive/page_snapshot.js @@ -73,6 +73,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 09a7299c6..6cf25dfd5 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 @@ -55,6 +59,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/visit.js b/src/core/drive/visit.js index d1929b03e..4fa57642d 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -154,10 +154,10 @@ export class Visit { } } - issueRequest() { + async issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest() - } else if (this.shouldIssueRequest() && !this.request) { + } else if (!this.request && await this.shouldIssueRequest()) { this.request = new FetchRequest(this, FetchMethod.get, this.location) this.request.perform() } @@ -231,14 +231,14 @@ export class Visit { } } - hasCachedSnapshot() { - return this.getCachedSnapshot() != null + async hasCachedSnapshot() { + return (await this.getCachedSnapshot()) != null } async loadCachedSnapshot() { const snapshot = await this.getCachedSnapshot() if (snapshot) { - const isPreview = this.shouldIssueRequest() + const isPreview = await this.shouldIssueRequest() this.render(async () => { this.cacheSnapshot() if (this.isSamePage) { @@ -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 { @@ -391,11 +391,11 @@ export class Visit { return typeof this.response == "object" } - shouldIssueRequest() { + async shouldIssueRequest() { if (this.isSamePage) { return false - } else if (this.action == "restore") { - return !this.hasCachedSnapshot() + } else if (this.action === "restore") { + return !(await this.hasCachedSnapshot()) } else { return this.willRender } 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 2a3dd8fe5..743380d7a 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -4,11 +4,13 @@ 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 { LimitedSet } from "./drive/limited_set" const session = new Session() const cache = new Cache(session) +const recentRequests = new LimitedSet(20) const { navigator } = session -export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer } +export { navigator, session, cache, recentRequests, PageRenderer, PageSnapshot, FrameRenderer } export { StreamActions } from "./streams/stream_actions" diff --git a/src/core/native/browser_adapter.js b/src/core/native/browser_adapter.js index 7505d7f8f..c3f1086de 100644 --- a/src/core/native/browser_adapter.js +++ b/src/core/native/browser_adapter.js @@ -22,11 +22,7 @@ export class BrowserAdapter { visitRequestStarted(visit) { this.progressBar.setValue(0) - if (visit.hasCachedSnapshot() || visit.action != "restore") { - this.showVisitProgressBarAfterDelay() - } else { - this.showProgressBar() - } + this.showVisitProgressBarAfterDelay() } visitRequestCompleted(visit) { 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 44edc7856..6ffcea244 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -263,9 +263,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) { @@ -328,8 +328,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..62801fd69 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -30,5 +30,14 @@ export const StreamActions = { targetElement.innerHTML = "" targetElement.append(this.templateContent) }) + }, + + refresh() { + const requestId = this.getAttribute("request-id") + const isRecentRequest = requestId && window.Turbo.recentRequests.has(requestId) + if (!isRecentRequest) { + window.Turbo.cache.exemptPageFromPreview() + window.Turbo.visit(window.location.href, { action: "replace" }) + } } } 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/http/recent_fetch_requests.js b/src/http/recent_fetch_requests.js new file mode 100644 index 000000000..620afd0a5 --- /dev/null +++ b/src/http/recent_fetch_requests.js @@ -0,0 +1,15 @@ +import { uuid } from "../util" + +const originalFetch = window.fetch + +window.fetch = async function(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}) + const requestUID = uuid() + window.Turbo.recentRequests.add(requestUID) + modifiedHeaders.append("X-Turbo-Request-Id", requestUID) + + return originalFetch(url, { + ...options, + headers: modifiedHeaders + }) +} diff --git a/src/index.js b/src/index.js index 07e3e097d..b76e23871 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import "./polyfills" import "./elements" import "./script_warning" +import "./http/recent_fetch_requests" import * as Turbo from "./core" diff --git a/src/tests/fixtures/frame_refresh_morph.html b/src/tests/fixtures/frame_refresh_morph.html new file mode 100644 index 000000000..bdfeed9fc --- /dev/null +++ b/src/tests/fixtures/frame_refresh_morph.html @@ -0,0 +1,3 @@ + +

Loaded morphed frame

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

Loaded reloadable frame

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

Page to be refreshed

+ + +

Frame to be morphed

+
+ + +

Frame to be reloaded

+
+ +
+ Preserve me! +
+ +
+

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..df4be0cfb --- /dev/null +++ b/src/tests/fixtures/page_refresh_replace.html @@ -0,0 +1,45 @@ + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be morphed

+
+ + +

Frame to be reloaded

+
+ +
+ 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/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/navigation_tests.js b/src/tests/functional/navigation_tests.js index f18a7ea1e..c4263d0f3 100644 --- a/src/tests/functional/navigation_tests.js +++ b/src/tests/functional/navigation_tests.js @@ -195,7 +195,13 @@ test("test following a POST form clears cache", async ({ page }) => { await page.click("#form-post-submit") await nextBeat() // 301 redirect response await nextBeat() // 200 response + + assert.equal(await page.textContent("h1"), "One") + await page.goBack() + await nextBeat() + + assert.equal(await page.textContent("h1"), "Navigation") assert.notOk(await hasSelector(page, "some-cached-element")) }) 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..22cf17e8d --- /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.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.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..73c0f4c5f --- /dev/null +++ b/src/tests/functional/page_refresh_tests.js @@ -0,0 +1,115 @@ +import { test, expect } from "@playwright/test" +import { + nextAttributeMutationNamed, + nextBeat, + nextEventNamed, + nextEventOnTarget, + noNextEventNamed, + noNextEventOnTarget +} 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 marked with refresh='morph'", 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, "refresh-morph", "turbo:before-frame-morph")).toBeTruthy() + expect(await noNextEventOnTarget(page, "refresh-reload", "turbo:before-frame-morph")).toBeTruthy() +}) + +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 reloads data-controller attributes after a morph", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + expect( + await nextAttributeMutationNamed(page, "stimulus-controller", "data-controller") + ).toEqual(null) + + await nextBeat() + + expect( + await nextAttributeMutationNamed(page, "stimulus-controller", "data-controller") + ).toEqual("test") +}) + +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 05ecc8589..bd62c8aa5 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("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.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"