@livetemplate/client 0.11.6 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1592,4 +1592,403 @@ describe("handleAreaSelectDirectives", () => {
1592
1592
  expect(document.querySelectorAll(".lvt-area-select-overlay").length).toBe(0);
1593
1593
  });
1594
1594
  });
1595
+ describe("handleURLHashDirective", () => {
1596
+ // The directive is a module-level singleton (a Map of armed elements
1597
+ // plus a single window-level hashchange listener), so every test
1598
+ // must tear down to avoid bleed between cases.
1599
+ afterEach(() => {
1600
+ (0, directives_1.teardownURLHashForRoot)(document.body);
1601
+ document.body.innerHTML = "";
1602
+ // Body persists across tests (innerHTML only resets descendants);
1603
+ // wipe attributes the previous test set on body itself.
1604
+ document.body.removeAttribute("lvt-fx:url-hash");
1605
+ document.body.removeAttribute("data-lvt-url-hash");
1606
+ // Reset URL hash without touching history (the directive uses
1607
+ // pushState/replaceState; jsdom keeps them isolated per test).
1608
+ window.history.replaceState(null, "", window.location.pathname);
1609
+ // The unencoded-hash warning dedupe Set is module-level and
1610
+ // outlives a single test; reset so a hash value reused across
1611
+ // tests still warns.
1612
+ (0, directives_1.__resetURLHashUnencodedWarnedForTesting)();
1613
+ jest.restoreAllMocks();
1614
+ });
1615
+ function mountBody(dataHash, action = "setURLHash") {
1616
+ const body = document.body;
1617
+ body.setAttribute("lvt-fx:url-hash", action);
1618
+ body.setAttribute("data-lvt-url-hash", dataHash);
1619
+ return body;
1620
+ }
1621
+ it("on first arm with empty location.hash and non-empty data-attr, mirrors data-attr into location.hash", () => {
1622
+ mountBody("README.md:L4");
1623
+ const send = jest.fn();
1624
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1625
+ expect(window.location.hash).toBe("#README.md:L4");
1626
+ // No dispatch because the URL didn't drive the change — the server
1627
+ // already knew the state (the data-attr came FROM the server).
1628
+ expect(send).not.toHaveBeenCalled();
1629
+ });
1630
+ it("on first arm with empty URL, uses replaceState (not pushState) — Back must not loop", () => {
1631
+ // Bug pinned: when initial URL is empty AND server has a hash,
1632
+ // the mirror step uses pushState by the path-comparison rule
1633
+ // ("" → "README.md" is a path change). That leaves a history
1634
+ // entry where Back lands the user on `url-without-hash`, which
1635
+ // re-arms the directive, which pushes the same hash again. Loop.
1636
+ // Fix: empty currentLocation → always replaceState.
1637
+ const lengthBefore = window.history.length;
1638
+ mountBody("README.md:L4");
1639
+ const send = jest.fn();
1640
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1641
+ expect(window.location.hash).toBe("#README.md:L4");
1642
+ expect(window.history.length).toBe(lengthBefore);
1643
+ });
1644
+ it("on first arm with non-empty location.hash differing from data-attr, dispatches the action with the URL hash", () => {
1645
+ window.history.replaceState(null, "", "#README.md:L4");
1646
+ mountBody(""); // server hasn't seen the hash yet
1647
+ const send = jest.fn();
1648
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1649
+ expect(send).toHaveBeenCalledTimes(1);
1650
+ expect(send.mock.calls[0][0]).toEqual({
1651
+ action: "setURLHash",
1652
+ data: { hash: "README.md:L4" },
1653
+ });
1654
+ // The URL is not rewritten — the server's next render will produce
1655
+ // the canonical data-attr, and we'll converge then.
1656
+ expect(window.location.hash).toBe("#README.md:L4");
1657
+ });
1658
+ it("on first arm with empty location.hash and empty data-attr, no-op (no dispatch, no URL write)", () => {
1659
+ mountBody("");
1660
+ const send = jest.fn();
1661
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1662
+ expect(send).not.toHaveBeenCalled();
1663
+ expect(window.location.hash).toBe("");
1664
+ });
1665
+ it("mirrors data-attr change to location.hash via replaceState when path component is unchanged", () => {
1666
+ mountBody("README.md:L4");
1667
+ const send = jest.fn();
1668
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1669
+ expect(window.location.hash).toBe("#README.md:L4");
1670
+ const lengthBefore = window.history.length;
1671
+ // Server re-render: same file, different line.
1672
+ document.body.setAttribute("data-lvt-url-hash", "README.md:L8");
1673
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1674
+ expect(window.location.hash).toBe("#README.md:L8");
1675
+ // replaceState keeps history depth flat: jsdom's history.length
1676
+ // increments only on pushState, not replaceState.
1677
+ expect(window.history.length).toBe(lengthBefore);
1678
+ });
1679
+ it("mirrors data-attr change to location.hash via pushState when path component changes", () => {
1680
+ mountBody("README.md:L4");
1681
+ const send = jest.fn();
1682
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1683
+ const lengthBefore = window.history.length;
1684
+ // Server re-render: different file.
1685
+ document.body.setAttribute("data-lvt-url-hash", "OTHER.md:L1");
1686
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1687
+ expect(window.location.hash).toBe("#OTHER.md:L1");
1688
+ expect(window.history.length).toBe(lengthBefore + 1);
1689
+ });
1690
+ it("on hashchange (user clicks a permalink), dispatches the action with the new hash", () => {
1691
+ mountBody("README.md:L4");
1692
+ const send = jest.fn();
1693
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1694
+ send.mockClear();
1695
+ // Simulate a user-driven hash change: set location.hash AND
1696
+ // synchronously fire the hashchange event. (jsdom queues
1697
+ // hashchange asynchronously when you assign location.hash, so we
1698
+ // dispatch manually to keep the test deterministic — same pattern
1699
+ // as area-select's synthetic pointer events.)
1700
+ window.history.replaceState(null, "", "#OTHER.md:L2");
1701
+ window.dispatchEvent(new Event("hashchange"));
1702
+ expect(send).toHaveBeenCalledTimes(1);
1703
+ expect(send.mock.calls[0][0]).toEqual({
1704
+ action: "setURLHash",
1705
+ data: { hash: "OTHER.md:L2" },
1706
+ });
1707
+ });
1708
+ it("idempotent re-arm with the same action does NOT add history entries", () => {
1709
+ mountBody("README.md:L4");
1710
+ const send = jest.fn();
1711
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1712
+ const lengthAfterArm = window.history.length;
1713
+ // Re-call with no data-attr change.
1714
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1715
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1716
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1717
+ expect(window.history.length).toBe(lengthAfterArm);
1718
+ expect(window.location.hash).toBe("#README.md:L4");
1719
+ });
1720
+ it("updateSend swaps the captured transport so a reconnect rebuilds dispatching", () => {
1721
+ mountBody("README.md:L4");
1722
+ const firstSend = jest.fn();
1723
+ (0, directives_1.handleURLHashDirective)(document.body, firstSend);
1724
+ firstSend.mockClear();
1725
+ // Re-arm with a NEW send (simulating a reconnect that rebuilt the
1726
+ // transport).
1727
+ const secondSend = jest.fn();
1728
+ (0, directives_1.handleURLHashDirective)(document.body, secondSend);
1729
+ // hashchange now should route through the second send only.
1730
+ window.history.replaceState(null, "", "#OTHER.md:L1");
1731
+ window.dispatchEvent(new Event("hashchange"));
1732
+ expect(firstSend).not.toHaveBeenCalled();
1733
+ expect(secondSend).toHaveBeenCalledTimes(1);
1734
+ expect(secondSend.mock.calls[0][0].data).toEqual({ hash: "OTHER.md:L1" });
1735
+ });
1736
+ it("teardown removes the armed element AND its hashchange listener", () => {
1737
+ mountBody("README.md:L4");
1738
+ const send = jest.fn();
1739
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1740
+ expect(window.location.hash).toBe("#README.md:L4");
1741
+ (0, directives_1.teardownURLHashForRoot)(document.body);
1742
+ // After teardown, a hashchange does NOT dispatch — the window
1743
+ // listener was removed when the armed map emptied.
1744
+ window.history.replaceState(null, "", "#OTHER.md:L1");
1745
+ window.dispatchEvent(new Event("hashchange"));
1746
+ expect(send).not.toHaveBeenCalled();
1747
+ });
1748
+ it("teardown via a descendant root cleans up a body-armed entry", () => {
1749
+ // Pin the body-ancestor branch of teardownURLHashForRoot: in
1750
+ // production, livetemplate calls teardown with the wrapper div
1751
+ // (inside body) as the root, but the directive lives on body.
1752
+ // The teardown must clean up the body entry too — otherwise
1753
+ // disconnect/reconnect cycles leak listeners.
1754
+ mountBody("README.md:L4");
1755
+ const wrapper = document.createElement("div");
1756
+ document.body.appendChild(wrapper);
1757
+ const send = jest.fn();
1758
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1759
+ (0, directives_1.teardownURLHashForRoot)(wrapper);
1760
+ window.history.replaceState(null, "", "#OTHER.md:L1");
1761
+ window.dispatchEvent(new Event("hashchange"));
1762
+ expect(send).not.toHaveBeenCalled();
1763
+ });
1764
+ it("sweep cleans up entries whose attribute was removed by a server diff", () => {
1765
+ mountBody("README.md:L4");
1766
+ const send = jest.fn();
1767
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1768
+ // Server diff removed the directive.
1769
+ document.body.removeAttribute("lvt-fx:url-hash");
1770
+ document.body.removeAttribute("data-lvt-url-hash");
1771
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1772
+ // The window listener should be gone now too — no dispatch.
1773
+ window.history.replaceState(null, "", "#OTHER.md:L1");
1774
+ window.dispatchEvent(new Event("hashchange"));
1775
+ expect(send).not.toHaveBeenCalled();
1776
+ });
1777
+ it("warns and skips when lvt-fx:url-hash is present but empty", () => {
1778
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
1779
+ document.body.setAttribute("lvt-fx:url-hash", "");
1780
+ const send = jest.fn();
1781
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1782
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("lvt-fx:url-hash requires an action name"));
1783
+ expect(send).not.toHaveBeenCalled();
1784
+ });
1785
+ it("ignores plain element-id hashes on initial load (no dispatch, no URL clobber)", () => {
1786
+ // Anchors like `#hero` (no `:`, no `/`, no `.`) belong to native
1787
+ // anchor / dialog / popover machinery — the directive must NOT
1788
+ // dispatch for them or it would race against setupHashLink.
1789
+ window.history.replaceState(null, "", "#hero");
1790
+ mountBody("");
1791
+ const send = jest.fn();
1792
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1793
+ expect(send).not.toHaveBeenCalled();
1794
+ expect(window.location.hash).toBe("#hero");
1795
+ });
1796
+ it("on initial load with non-deep-link hash AND non-empty server data-attr, leaves the URL alone", () => {
1797
+ // The browser navigated to `#hero` (a popover id, say). The
1798
+ // server happens to have selected a default file, so the data-
1799
+ // attr is non-empty. Before the fix, the else-branch mirrored
1800
+ // the server's hash into the URL and silently closed the
1801
+ // popover. After the fix, the URL is left alone — popover
1802
+ // wins.
1803
+ window.history.replaceState(null, "", "#hero");
1804
+ mountBody("README.md");
1805
+ const send = jest.fn();
1806
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1807
+ expect(send).not.toHaveBeenCalled();
1808
+ expect(window.location.hash).toBe("#hero");
1809
+ });
1810
+ it("server changing selection while a non-deep-link URL hash is open does NOT clobber", () => {
1811
+ // Round-8 bot edge case: case (b) leaves the URL on #hero and
1812
+ // seeds currentDataHash = dataHash. If the server then pushes a
1813
+ // DIFFERENT selection (rare in prereview — server state changes
1814
+ // only on user action — but possible via cross-tab sync or a
1815
+ // server-driven update), the mirror's path-comparison would
1816
+ // have written the new file's hash and clobbered #hero. Fixed
1817
+ // by generalising the non-deep-link-URL guard to cover any
1818
+ // dataHash, not just empty.
1819
+ window.history.replaceState(null, "", "#hero");
1820
+ mountBody("README.md");
1821
+ const send = jest.fn();
1822
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1823
+ expect(window.location.hash).toBe("#hero");
1824
+ // Server pushes a different selection — URL must NOT change.
1825
+ document.body.setAttribute("data-lvt-url-hash", "OTHER.md");
1826
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1827
+ expect(window.location.hash).toBe("#hero");
1828
+ });
1829
+ it("server clearing the data-attr DOES clear a deep-link URL hash (deliberate, symmetric)", () => {
1830
+ // Symmetric to "non-deep-link is never clobbered": if the URL
1831
+ // is a deep-link we own AND the server transitions to
1832
+ // no-selection, the URL should clear. Pin this as deliberate
1833
+ // so a future refactor doesn't accidentally make all
1834
+ // empty-dataHash mirrors no-op.
1835
+ mountBody("README.md:L4");
1836
+ const send = jest.fn();
1837
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1838
+ expect(window.location.hash).toBe("#README.md:L4");
1839
+ document.body.setAttribute("data-lvt-url-hash", "");
1840
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1841
+ expect(window.location.hash).toBe("");
1842
+ });
1843
+ it("deselection (deep-link → empty) uses pushState — Back returns to the selection (deliberate)", () => {
1844
+ // Pin the path-comparison branch's behavior for the deselect
1845
+ // case: server transitions `data-lvt-url-hash` from a selected
1846
+ // file to "", path comparison says oldPath !== newPath (one is
1847
+ // empty), the mirror uses pushState. That leaves a history
1848
+ // entry so Back returns the user to their prior selection.
1849
+ // Intentional — matches the file-switch UX.
1850
+ mountBody("README.md:L4");
1851
+ const send = jest.fn();
1852
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1853
+ expect(window.location.hash).toBe("#README.md:L4");
1854
+ const lengthBeforeDeselect = window.history.length;
1855
+ document.body.setAttribute("data-lvt-url-hash", "");
1856
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1857
+ expect(window.location.hash).toBe("");
1858
+ expect(window.history.length).toBe(lengthBeforeDeselect + 1);
1859
+ });
1860
+ it("server clearing the data-attr does NOT wipe a non-deep-link URL hash", () => {
1861
+ // Server first has README.md selected → URL becomes
1862
+ // `#README.md`. Then the user opens a popover whose id is
1863
+ // `#hero` (URL is now `#hero`). Then the server transitions to
1864
+ // no-selection (e.g. ClearSelection) and renders data-attr="".
1865
+ // The directive must not clobber the popover hash.
1866
+ mountBody("README.md");
1867
+ const send = jest.fn();
1868
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1869
+ expect(window.location.hash).toBe("#README.md");
1870
+ // User navigates to a popover-shaped hash (simulated).
1871
+ window.history.replaceState(null, "", "#hero");
1872
+ // Server clears state → empty data-attr.
1873
+ document.body.setAttribute("data-lvt-url-hash", "");
1874
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1875
+ expect(window.location.hash).toBe("#hero");
1876
+ });
1877
+ it("case-(b) + user clears URL: URL stays empty while server keeps its selection (deliberate divergence)", () => {
1878
+ // Load with a popover-shaped hash, server has a file selected.
1879
+ // Case (b) seeds entry.currentDataHash with the server's value.
1880
+ window.history.replaceState(null, "", "#hero");
1881
+ mountBody("README.md");
1882
+ const send = jest.fn();
1883
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1884
+ expect(window.location.hash).toBe("#hero");
1885
+ // User clears the URL bar.
1886
+ window.history.replaceState(null, "", window.location.pathname);
1887
+ window.dispatchEvent(new Event("hashchange"));
1888
+ expect(send).not.toHaveBeenCalled();
1889
+ // Server re-renders with the SAME selection (data-attr unchanged).
1890
+ // The early-exit guard (currentDataHash === dataHash) means we
1891
+ // never mirror — URL stays empty, server stays on README.md.
1892
+ // This divergence is intentional: case (b) said "the URL isn't
1893
+ // ours, leave it alone", and a user clearing the URL doesn't
1894
+ // change that — they're still navigating outside our turf.
1895
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1896
+ expect(window.location.hash).toBe("");
1897
+ expect(send).not.toHaveBeenCalled();
1898
+ // Convergence only happens when the user takes an in-app action
1899
+ // that changes server state (e.g. SelectFile to OTHER.md): the
1900
+ // data-attr now differs from currentDataHash and the mirror
1901
+ // step writes the new hash.
1902
+ document.body.setAttribute("data-lvt-url-hash", "OTHER.md");
1903
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1904
+ expect(window.location.hash).toBe("#OTHER.md");
1905
+ });
1906
+ it("user clearing the URL hash (hashchange to empty) does NOT dispatch — server stays source of truth", () => {
1907
+ // The directive treats the server as source of truth for
1908
+ // "what's selected". An empty location.hash is "user navigated
1909
+ // away from the hash" but not "deselect". Deselect happens via
1910
+ // in-app affordances that flow through the server, which then
1911
+ // emits an empty data-attr.
1912
+ mountBody("README.md:L4");
1913
+ const send = jest.fn();
1914
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1915
+ send.mockClear();
1916
+ // User clears the URL bar.
1917
+ window.history.replaceState(null, "", window.location.pathname);
1918
+ window.dispatchEvent(new Event("hashchange"));
1919
+ expect(send).not.toHaveBeenCalled();
1920
+ });
1921
+ it("ignores plain element-id hashes on hashchange", () => {
1922
+ mountBody("README.md:L4");
1923
+ const send = jest.fn();
1924
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1925
+ send.mockClear();
1926
+ // User clicks an HTML anchor link (e.g. inside the TOC overlay).
1927
+ window.history.replaceState(null, "", "#some-section");
1928
+ window.dispatchEvent(new Event("hashchange"));
1929
+ expect(send).not.toHaveBeenCalled();
1930
+ });
1931
+ it("preserves history.state across the mirror write (doesn't clobber co-tenant SPA state)", () => {
1932
+ // Co-tenant code stores something in history.state — e.g. scroll
1933
+ // position or modal flag. Our directive's push/replaceState must
1934
+ // pass that state through, not overwrite with null.
1935
+ window.history.replaceState({ scroll: 42, modal: "open" }, "", window.location.pathname);
1936
+ mountBody("README.md:L4");
1937
+ const send = jest.fn();
1938
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1939
+ expect(window.location.hash).toBe("#README.md:L4");
1940
+ expect(window.history.state).toEqual({ scroll: 42, modal: "open" });
1941
+ // Same on a path-change pushState path.
1942
+ document.body.setAttribute("data-lvt-url-hash", "OTHER.md");
1943
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1944
+ expect(window.location.hash).toBe("#OTHER.md");
1945
+ expect(window.history.state).toEqual({ scroll: 42, modal: "open" });
1946
+ });
1947
+ it("warns when data-lvt-url-hash contains characters that should be percent-encoded", () => {
1948
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
1949
+ // Note: jsdom's location.hash setter percent-encodes spaces, so
1950
+ // we mount the directive with a value containing a literal space
1951
+ // and expect a warn before the directive writes the URL.
1952
+ mountBody("path with space.md");
1953
+ (0, directives_1.handleURLHashDirective)(document.body, jest.fn());
1954
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("should be percent-encoded"));
1955
+ });
1956
+ it.each([
1957
+ ["bracket [", "path[v1].md"],
1958
+ ["bracket ]", "list]item.md"],
1959
+ ["raw percent", "50%off.md"],
1960
+ ["raw quote", `name".md`],
1961
+ ["raw less-than", "x<y.md"],
1962
+ ])("warns on %s", (_label, hash) => {
1963
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
1964
+ mountBody(hash);
1965
+ (0, directives_1.handleURLHashDirective)(document.body, jest.fn());
1966
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("should be percent-encoded"));
1967
+ });
1968
+ it("valid percent-encoded sequence does NOT warn", () => {
1969
+ // `%20` is a properly percent-encoded space — no warn.
1970
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { });
1971
+ mountBody("path%20with%20space.md");
1972
+ (0, directives_1.handleURLHashDirective)(document.body, jest.fn());
1973
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("should be percent-encoded"));
1974
+ });
1975
+ it("data-attr update unchanged from last mirror is a no-op (no history pollution)", () => {
1976
+ mountBody("README.md:L4");
1977
+ const send = jest.fn();
1978
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1979
+ // User clicks a permalink anchor → location.hash changes to the
1980
+ // same value the data-attr already had. The hashchange dispatch
1981
+ // updates currentDataHash to the same value; subsequent renders
1982
+ // with the same data-attr should still no-op (no extra history
1983
+ // entries when the server echoes back the same hash).
1984
+ window.history.replaceState(null, "", "#OTHER.md:L9");
1985
+ window.dispatchEvent(new Event("hashchange"));
1986
+ const lengthBefore = window.history.length;
1987
+ send.mockClear();
1988
+ document.body.setAttribute("data-lvt-url-hash", "OTHER.md:L9");
1989
+ (0, directives_1.handleURLHashDirective)(document.body, send);
1990
+ expect(window.history.length).toBe(lengthBefore);
1991
+ expect(window.location.hash).toBe("#OTHER.md:L9");
1992
+ });
1993
+ });
1595
1994
  //# sourceMappingURL=directives.test.js.map