diff --git a/package.json b/package.json index 03b07fa02..74c0d20fc 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "idiomorph": "^0.0.9" + }, "devDependencies": { "@open-wc/testing": "^3.1.7", "@playwright/test": "^1.28.0", diff --git a/src/core/drive/error_renderer.js b/src/core/drive/error_renderer.js index 0e5f2ce39..0d3b48b6b 100644 --- a/src/core/drive/error_renderer.js +++ b/src/core/drive/error_renderer.js @@ -3,6 +3,10 @@ import { Renderer } from "../renderer" export class ErrorRenderer extends Renderer { static renderElement(currentElement, newElement) { + ErrorRenderer.replace(currentElement, newElement) + } + + static replace(currentElement, newElement) { const { documentElement, body } = document documentElement.replaceChild(newElement, body) 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..1eedaacf6 --- /dev/null +++ b/src/core/drive/morph_renderer.js @@ -0,0 +1,20 @@ +import { Renderer } from "../renderer" +import { Morph } from "../morph" + +export class MorphRenderer extends Renderer { + static renderElement(currentElement, newElement) { + MorphRenderer.morph(currentElement, newElement) + } + + static morph(currentElement, newElement) { + Morph.render(currentElement, newElement) + } + + async render() { + if (this.willRender) this.renderElement(this.currentElement, this.newElement) + } + + get renderMethod() { + return "morph" + } +} diff --git a/src/core/drive/page_renderer.js b/src/core/drive/page_renderer.js index 0c1a222e6..3d6db9542 100644 --- a/src/core/drive/page_renderer.js +++ b/src/core/drive/page_renderer.js @@ -3,6 +3,10 @@ import { activateScriptElement, waitForLoad } from "../../util" export class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { + PageRenderer.replace(currentElement, newElement) + } + + static replace(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement) } else { 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..bff0815f0 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, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true @@ -28,7 +32,7 @@ export class PageView extends View { renderError(snapshot, visit) { visit?.changeHistory() - const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false) + const renderer = new ErrorRenderer(this.snapshot, snapshot, false) return this.render(renderer) } @@ -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..4a85196d8 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) @@ -307,7 +307,7 @@ export class FrameController { if (newFrameElement) { const snapshot = new Snapshot(newFrameElement) - const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false) + const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, false, false) if (this.view.renderPromise) await this.view.renderPromise this.changeHistory() diff --git a/src/core/frames/frame_renderer.js b/src/core/frames/frame_renderer.js index 1384465d6..a9b974077 100644 --- a/src/core/frames/frame_renderer.js +++ b/src/core/frames/frame_renderer.js @@ -1,8 +1,17 @@ -import { activateScriptElement, nextAnimationFrame } from "../../util" +import { activateScriptElement, nextAnimationFrame, dispatch } from "../../util" import { Renderer } from "../renderer" +import { Morph } from "../morph" export class FrameRenderer extends Renderer { static renderElement(currentElement, newElement) { + if (currentElement.src && currentElement.refresh === "morph") { + FrameRenderer.morph(currentElement, newElement) + } else { + FrameRenderer.replace(currentElement, newElement) + } + } + + static replace(currentElement, newElement) { const destinationRange = document.createRange() destinationRange.selectNodeContents(currentElement) destinationRange.deleteContents() @@ -15,6 +24,15 @@ export class FrameRenderer extends Renderer { } } + static morph(currentElement, newElement) { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }) + + Morph.render(currentElement, newElement.children, "innerHTML") + } + constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate @@ -24,6 +42,14 @@ export class FrameRenderer extends Renderer { return true } + get renderMethod() { + if (this.currentElement.refresh === "morph") { + return "morph" + } else { + return super.renderMethod + } + } + async render() { await nextAnimationFrame() this.preservingPermanentElements(() => { 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/morph.js b/src/core/morph.js new file mode 100644 index 000000000..42724aa1a --- /dev/null +++ b/src/core/morph.js @@ -0,0 +1,54 @@ +import "idiomorph" +import { nextAnimationFrame } from "../util" + +export class Morph { + static render(currentElement, newElement, morphStyle) { + const morph = new this(currentElement, newElement) + + morph.render(morphStyle) + } + + constructor(currentElement, newElement) { + this.currentElement = currentElement + this.newElement = newElement + } + + render(morphStyle = "outerHTML") { + window.Idiomorph.morph(this.currentElement, this.newElement, { + ignoreActiveValue: true, + morphStyle: morphStyle, + callbacks: { + beforeNodeMorphed: shouldMorphElement, + beforeNodeRemoved: shouldRemoveElement, + afterNodeMorphed: reloadStimulusControllers + } + }) + + this.#remoteFrames.forEach((frame) => frame.reload()) + } + + get #remoteFrames() { + return this.currentElement.querySelectorAll("turbo-frame[src]") + } +} + +function shouldRemoveElement(node) { + return shouldMorphElement(node) +} + +function shouldMorphElement(node) { + if (node instanceof HTMLElement) { + return !node.hasAttribute("data-turbo-permanent") + } else { + return true + } +} + +async function reloadStimulusControllers(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) + } +} 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..414335b88 100644 --- a/src/core/renderer.js +++ b/src/core/renderer.js @@ -3,12 +3,16 @@ import { Bardo } from "./bardo" export class Renderer { #activeElement = null - constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + static renderElement(currentElement, newElement) { + // Abstract method + } + + constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview this.willRender = willRender - this.renderElement = renderElement + this.renderElement = this.constructor.renderElement this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) } @@ -79,4 +83,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/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/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/frames.html b/src/tests/fixtures/frames.html index d6a82d831..fc8de4047 100644 --- a/src/tests/fixtures/frames.html +++ b/src/tests/fixtures/frames.html @@ -62,6 +62,17 @@

Frames: #hello

+ +

Source: #refresh-morph

+ + Navigate #refresh-morph + +
+ + +
+
+ advance #hello diff --git a/src/tests/fixtures/frames/refresh_morph.html b/src/tests/fixtures/frames/refresh_morph.html new file mode 100644 index 000000000..a08cca945 --- /dev/null +++ b/src/tests/fixtures/frames/refresh_morph.html @@ -0,0 +1,23 @@ + + + + + Frames: Refresh=Morph + + + + +

Refresh=Morph

+ + +

Destination: #refresh-morph

+ + Navigate #refresh-morph + +
+ + +
+
+ + 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/frame_tests.js b/src/tests/functional/frame_tests.js index bd06f2d15..f760ccf94 100644 --- a/src/tests/functional/frame_tests.js +++ b/src/tests/functional/frame_tests.js @@ -4,6 +4,7 @@ import { attributeForSelector, hasSelector, listenForEventOnTarget, + locatorHasFocus, nextAttributeMutationNamed, noNextAttributeMutationNamed, nextBeat, @@ -882,6 +883,52 @@ test("test navigating a eager frame with a link[method=get] that does not fetch assert.equal(pathname(page.url()), "/src/tests/fixtures/page_with_eager_frame.html") }) +test("test driving a morph frame with link preserves focus", async ({ page }) => { + const frame = await page.locator("turbo-frame#refresh-morph") + const link = await frame.locator("a:first-of-type") + + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + + await link.focus() + await link.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(link), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Destination: #refresh-morph") + assert.equal(await frame.count("#input-in-morph"), 1) + + await link.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(link), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + assert.equal(await frame.count("#input-in-morph"), 1) +}) + +test("test driving a morph frame with form preserves focus", async ({ page }) => { + const frame = await page.locator("turbo-frame#refresh-morph") + const input = await frame.locator("#input-in-refresh-morph") + + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + + await input.type("hello") + await input.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(input), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Destination: #refresh-morph") + await expect(input).toHaveValue("hello") + assert.equal(await frame.count("#input-in-morph"), 1) + + await input.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(input), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + await expect(input).toHaveValue("hello") + assert.equal(await frame.count("#input-in-morph"), 1) +}) + test("form submissions from frames clear snapshot cache", async ({ page }) => { await page.evaluate(() => { document.querySelector("h1").textContent = "Changed" 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..a5753dced 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) { @@ -201,7 +205,11 @@ export function searchParams(url) { } export function selectorHasFocus(page, selector) { - return page.locator(selector).evaluate((element) => element === document.activeElement) + return locatorHasFocus(page.locator(selector)) +} + +export function locatorHasFocus(locator) { + return locator.evaluate((element) => element === document.activeElement) } export function setLocalStorageFromEvent(page, eventName, storageKey, storageValue) { 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..36a8bcdd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1865,6 +1865,11 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +idiomorph@^0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/idiomorph/-/idiomorph-0.0.9.tgz#938d5964031381b0713398fb283aa3754306fa1b" + integrity sha512-X7SGsldE5jQD+peLjNLAnIJDEZtGpuLrNRUBrTWMMnzrx9gwy5U+SCRhaifO2v2Z+8j09IY2J+EYaxHsOLTD0A== + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"