Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unexpected behaviour when an element doesn't have a target #7

Open
polyrand opened this issue Jan 9, 2025 · 0 comments
Open

Unexpected behaviour when an element doesn't have a target #7

polyrand opened this issue Jan 9, 2025 · 0 comments

Comments

@polyrand
Copy link

polyrand commented Jan 9, 2025

Hi. Thanks for this contribution and proposal. I'd like this see something like this built into browsers/HTML.

While reading the code and playing around with it, I noticed what could be a bug. There's a replacePage function, which should replace the full document. This function is supposedly used when there's no target in the element, as seen in the ajax function and in the README:

When targets are not supplied on buttons and forms, Triptych simulates the full-page behavior to the best of its ability. It replaces the entire document and uses the history API to update the browser URL and history. Clicking "back" should basically work as expected.

However, the previous condition that checks that the target exists, seems to invalidate this, and logs an error then returns null. Which makes it impossible to implement a behaviour to replace the full page.

Here's an example self-contained HTML document to showcase the bug. I copy-pasted the source code as of commit 70803de and replaced the fetch() call with a sample text to make it easier to run.

<!DOCTYPE html>
<html>
  <head>
    <script>
      // These are vars so that they just get redeclared if the script is re-executed
      var ADDITIONAL_FORM_METHODS = ["PUT", "PATCH", "DELETE"];
      var EXISTING_TARGET_KEYWORDS = ["_self", "_blank", "_parent", "_top", "_unfencedTop"];

      /**
       * Mimic a full-page navigattion with the history API and a full-document replacement.
       *
       * @param {string} html
       * @param {string} url
       * @param {boolean} addHistory
       */
      function replacePage(html, url, addHistory) {
        if (!history.state) {
          const state = { html: document.documentElement.innerHTML, url: window.location.href };
          history.replaceState(state, "");
        }

        // Replace the page's HTML and, if applicable, add it to the history state
        document.querySelector("html").innerHTML = html;
        if (addHistory) history.pushState({ html, url }, "", url);

        // We have to manually execute all the scripts that we inserted into the page
        document.querySelectorAll("script").forEach((oldScript) => {
          const newScript = document.createElement("script");
          Array.from(oldScript.attributes).forEach((attr) => {
            newScript.setAttribute(attr.name, attr.value);
          });

          const scriptText = document.createTextNode(oldScript.innerHTML);
          newScript.appendChild(scriptText);
          oldScript.replaceWith(newScript);
        });
      }

      /**
       * Return the function that performs the request and replacement when fired
       *
       * @param {string} rawUrl
       * @param {string} method
       * @param {FormData} formData
       * @param {string} target
       */
      function ajax(rawUrl, method, formData, target) {
        // Remove the query string form the URL, if it's present
        const queryIndex = rawUrl.indexOf("?");
        let url = queryIndex > -1 ? rawUrl.substring(0, queryIndex) : rawUrl;

        /** @param {Event} e */
        return async (e) => {
          // End immediately if there's an iFrame with the target name
          if (document.querySelector(`iframe[name="${target}"]`)) return null;
          // This comes after the iFrame check so that normal iFrame targeting is preserved
          e.preventDefault();

          // Replace self if the target is `_this`, otherwise replace the target from a querySelector
          let targetElement;
          if (target === "_this") {
            targetElement = e.target;
          } else {
            targetElement = document.querySelector(target);
          }

          if (!targetElement) {
            console.error(`no element found for target ${target} - ignorning`);
            return null;
          }

          const opts = { method };
          // If the methods allow for request bodies, add the data to the request body
          if (method !== "GET" && method !== "DELETE") {
            opts.body = formData;
            // Otherwise, if there is formData at all, add it to the URL params
          } else if (formData != null) {
            // != null checks for both null and undefined
            const queryParams = new URLSearchParams(formData).toString();
            url += "?" + queryParams;
          }

          //   const res = await fetch(url, opts);
          const responseText = '<div data-target="1"><p>test text</p></div>';
          if (targetElement) {
            const template = document.createElement("template");
            template.innerHTML = responseText;
            processNode(template);

            // @ts-ignore - all the targets are going to be Elements
            targetElement.replaceWith(template.content);
          } else {
            replacePage(responseText, res.url);
          }
        };
      }

      /**
       * Add Triptych functionality to this DOM node and all its children
       *
       * @param {Document | Element} node
       */
      function processNode(node) {
        // Find all the forms
        const forms = node.querySelectorAll("form");
        for (const form of forms) {
          const method = form.getAttribute("method");
          const target = form.getAttribute("target") || undefined;
          // Only process forms that a) have subtree targets or b) have new methods
          if (target || ADDITIONAL_FORM_METHODS.includes(method)) {
            const url = form.getAttribute("action");
            const formData = new FormData(form);
            form.addEventListener("submit", ajax(url, method, formData, target));
          }
        }

        // Find the buttons with an action attribute
        const buttons = node.querySelectorAll("button[action]");
        for (const button of buttons) {
          const url = button.getAttribute("action");
          const target = button.getAttribute("target");
          const method = button.getAttribute("method") || "GET";
          let formData;
          if (button.getAttribute("name")) {
            formData = new FormData();
            formData.append(button.getAttribute("name"), button.getAttribute("value"));
          }
          button.addEventListener("click", ajax(url, method, formData, target));
        }

        // Find the links with a target attribute
        const links = node.querySelectorAll("a[target]");
        for (const link of links) {
          const target = link.getAttribute("target");
          const url = link.getAttribute("href");
          if (url && !EXISTING_TARGET_KEYWORDS.includes(target)) {
            link.addEventListener("click", ajax(url, "GET", undefined, target));
          }
        }
      }

      // Process all the nodes once when the DOM is ready
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", () => {
          processNode(document);
        });
      } else {
        processNode(document);
      }

      // Handle forward/back buttons
      window.addEventListener(
        "popstate",
        (event) => {
          if (event.state) replacePage(event.state.html, event.state.url, true);
        },
        { once: true }
      );
    </script>
  </head>
  <body>
    <div data-target="1"></div>

    <button action="./something" target="[data-target='1']">Btn Target 1</button>
    <button action="./something">Btn No Target</button>
  </body>
</html>

The action="./something" can be ignored, the response is always overridden by:

const responseText = '<div data-target="1"><p>test text</p></div>';

When I click:

<button action="./something" target="[data-target='1']">Btn Target 1</button>

The behaviour is as expected. But when I click:

<button action="./something">Btn No Target</button>

I would expect a full replacement of the document element, but that's not the case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant