diff --git a/ember-primitives/src/polyfill/anchor-hash-targets.ts b/ember-primitives/src/polyfill/anchor-hash-targets.ts new file mode 100644 index 00000000..40f369df --- /dev/null +++ b/ember-primitives/src/polyfill/anchor-hash-targets.ts @@ -0,0 +1,196 @@ +/* eslint-disable ember/no-runloop */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { warn } from '@ember/debug'; +import { isDestroyed, isDestroying, registerDestructor } from '@ember/destroyable'; +import { getOwner } from '@ember/owner'; +import { schedule } from '@ember/runloop'; +import { waitForPromise } from '@ember/test-waiters'; + +import type Owner from '@ember/owner'; +import type Route from '@ember/routing/route'; +import type EmberRouter from '@ember/routing/router'; + +type Transition = Parameters[0]; +type TransitionWithPrivateAPIs = Transition & { + intent?: { + url: string; + }; +}; + +export function withHashSupport(AppRouter: typeof EmberRouter): typeof AppRouter { + return class RouterWithHashSupport extends AppRouter { + constructor(...args: any[]) { + super(...args); + + void setupHashSupport(this); + } + }; +} + +export function scrollToHash(hash: string) { + const selector = `[name="${hash}"]`; + const element = document.getElementById(hash) || document.querySelector(selector); + + if (!element) { + warn(`Tried to scroll to element with id or name "${hash}", but it was not found`, { + id: 'no-hash-target', + }); + + return; + } + + /** + * NOTE: the ember router does not support hashes in the URL + * https://github.com/emberjs/rfcs/issues/709 + * + * this means that when testing hash changes in the URL, + * we have to assert against the window.location, rather than + * the self-container currentURL helper + * + * NOTE: other ways of changing the URL, but without the smoothness: + * - window[.top].location.replace + */ + + element.scrollIntoView({ behavior: 'smooth' }); + + if (hash !== window.location.hash) { + const withoutHash = location.href.split('#')[0]; + const nextUrl = `${withoutHash}#${hash}`; + // most browsers ignore the title param of pushState + const titleWithoutHash = document.title.split(' | #')[0]; + const nextTitle = `${titleWithoutHash} | #${hash}`; + + history.pushState({}, nextTitle, nextUrl); + document.title = nextTitle; + } +} + +function isLoadingRoute(routeName: string) { + return routeName.endsWith('_loading') || routeName.endsWith('.loading'); +} + +async function setupHashSupport(router: EmberRouter) { + let initialURL: string | undefined; + const owner = getOwner(router) as Owner; + + await new Promise((resolve) => { + const interval = setInterval(() => { + const { currentURL, currentRouteName } = router as any; /* Private API */ + + if (currentURL && !isLoadingRoute(currentRouteName)) { + clearInterval(interval); + initialURL = currentURL; + resolve(null); + } + }, 100); + }); + + if (isDestroyed(owner) || isDestroying(owner)) { + return; + } + + /** + * This handles the initial Page Load, which is not imperceptible through + * route{Did,Will}Change + * + */ + requestAnimationFrame(() => { + void eventuallyTryScrollingTo(owner, initialURL); + }); + + const routerService = owner.lookup('service:router'); + + function handleHashIntent(transition: TransitionWithPrivateAPIs) { + const { url } = transition.intent || {}; + + if (!url) { + return; + } + + void eventuallyTryScrollingTo(owner, url); + } + + // @ts-expect-error -- I don't want to fix this + routerService.on('routeDidChange', handleHashIntent); + + registerDestructor(router, () => { + routerService.off('routeDidChange', handleHashIntent); + }); +} + +const CACHE = new WeakMap(); + +async function eventuallyTryScrollingTo(owner: Owner, url?: string) { + // Prevent quick / rapid transitions from continuing to observe beyond their URL-scope + CACHE.get(owner)?.disconnect(); + + if (!url) return; + + const [, hash] = url.split('#'); + + if (!hash) return; + + await waitForPromise(uiSettled(owner)); + + if (isDestroyed(owner) || isDestroying(owner)) { + return; + } + + scrollToHash(hash); +} + +const TIME_SINCE_LAST_MUTATION = 500; // ms +const MAX_TIMEOUT = 2000; // ms + +/** + * exported for testing + * + * @internal + */ +export async function uiSettled(owner: Owner) { + const timeStarted = new Date().getTime(); + let lastMutationAt = Infinity; + let totalTimeWaited = 0; + + const observer = new MutationObserver(() => { + lastMutationAt = new Date().getTime(); + }); + + CACHE.set(owner, observer); + + observer.observe(document.body, { childList: true, subtree: true }); + + /** + * Wait for DOM mutations to stop until MAX_TIMEOUT + */ + await new Promise((resolve) => { + let frame: number; + + function requestTimeCheck() { + if (frame) cancelAnimationFrame(frame); + + if (isDestroyed(owner) || isDestroying(owner)) { + return; + } + + frame = requestAnimationFrame(() => { + totalTimeWaited = new Date().getTime() - timeStarted; + + const timeSinceLastMutation = new Date().getTime() - lastMutationAt; + + if (totalTimeWaited >= MAX_TIMEOUT) { + return resolve(totalTimeWaited); + } + + if (timeSinceLastMutation >= TIME_SINCE_LAST_MUTATION) { + return resolve(totalTimeWaited); + } + + schedule('afterRender', requestTimeCheck); + }); + } + + schedule('afterRender', requestTimeCheck); + }); +} diff --git a/package.json b/package.json index 24d50687..a0aeb27b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "_start:tests": "pnpm --filter test-app start", "build": "turbo run build --output-logs errors-only", "lint": "turbo run _:lint --output-logs errors-only", - "lint:fix": "turbo run _:lint:fix --output-logs errors-only", + "lint:fix": "turbo run _:lint:fix", "start": "pnpm build; concurrently 'npm:_start:*' --prefix ' ' --restart-after 5000 --restart-tries -1", "test": "turbo run test --output-logs errors-only" }, diff --git a/test-app/tests/index.html b/test-app/tests/index.html index c321bb03..31b670ec 100644 --- a/test-app/tests/index.html +++ b/test-app/tests/index.html @@ -32,7 +32,7 @@ diff --git a/test-app/tests/polyfill/-helpers.ts b/test-app/tests/polyfill/-helpers.ts new file mode 100644 index 00000000..f0aa4178 --- /dev/null +++ b/test-app/tests/polyfill/-helpers.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable ember/no-private-routing-service */ +import Router from '@ember/routing/router'; +import { settled } from '@ember/test-helpers'; + +import { withHashSupport } from 'ember-primitives/polyfill/anchor-hash-targets'; + +type MapFunction = Parameters<(typeof Router)['map']>[0]; + +interface SetupRouterOptions { + active?: string | string[]; + map?: MapFunction; + rootURL?: string; +} + +const noop = () => {}; + +/** + * A test helper to define a new router map in the context of a test. + * + * Useful for when an integration test may need to interact with the router service, + * but since you're only rendering a component, routing isn't enabled (pre Ember 3.25). + * + * Also useful for testing custom link components. + * + * @example + * + * import { setupRouter } from '@crowdstrike/test-helpers'; + * + * module('tests that need a router', function(hooks) { + * setupRouter(hooks, { + * active: ['some-route-path.foo', 2], + * map: function() { + * this.route('some-route-path', function() { + * this.route('hi'); + * this.route('foo', { path: ':dynamic_segment' }); + * }); + * }, + * }); + * }) + * + * + * @param {NestedHooks} hooks + * @param {Object} configuration - router configuration, as it would be defined in router.js + * @param {Array} [configuration.active] - route segments that make up the active route + * @param {Function} configuration.map - the router map + * @param {string} [configuration.rootURL] - the root URL of the application + */ +export function setupRouter( + hooks: NestedHooks, + { active, map = noop, rootURL = '/' }: SetupRouterOptions = {} +) { + let originalMaps: unknown[] = []; + + hooks.beforeEach(async function () { + this.owner.register( + 'router:main', + withHashSupport( + class extends Router { + location = 'none'; + rootURL = './'; + } + ) + ); + + // @ts-expect-error - not fixing - private api + const router = this.owner.resolveRegistration('router:main'); + + router.rootURL = rootURL; + originalMaps = router.dslCallbacks; + router.dslCallbacks = []; + + router.map(map); + // @ts-expect-error - not fixing - private api + this.owner.lookup('router:main').setupRouter(); + + if (active) { + const routerService = this.owner.lookup('service:router'); + + routerService.transitionTo(...ensureArray(active)); + await settled(); + } + }); + + hooks.afterEach(function () { + // @ts-expect-error - not fixing - private api + const router = this.owner.resolveRegistration('router:main'); + + router.dslCallbacks = originalMaps; + }); +} + +/** + * For setting up the currently configured router in your app + * + */ +export function setupAppRouter(hooks: NestedHooks) { + hooks.beforeEach(function () { + // @ts-expect-error - not fixing - private api + this.owner.lookup('router:main').setupRouter(); + }); +} + +export function ensureArray(maybeArray?: T | T[]): T[] { + if (Array.isArray(maybeArray)) { + return maybeArray; + } + + if (!maybeArray) { + return []; + } + + return [maybeArray]; +} diff --git a/test-app/tests/polyfill/anchor-hash-targets-test.gts b/test-app/tests/polyfill/anchor-hash-targets-test.gts new file mode 100644 index 00000000..3771b0ef --- /dev/null +++ b/test-app/tests/polyfill/anchor-hash-targets-test.gts @@ -0,0 +1,320 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import Component from '@glimmer/component'; +import Controller from '@ember/controller'; +import { assert as debugAssert } from '@ember/debug'; +import { hash } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { LinkTo } from '@ember/routing'; +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { click, find, settled, visit, waitUntil } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; + +import { scrollToHash, uiSettled } from 'ember-primitives/polyfill/anchor-hash-targets'; + +import { setupRouter } from './-helpers.ts'; + +import type RouterService from '@ember/routing/router-service'; + +module('anchor-hash-target', function (hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(() => { + location.hash = ''; + }); + hooks.afterEach(() => { + location.hash = ''; + }); + + // TODO: PR this to qunit-dom as assort.dom(element).isInView(); + // assert.dom().isVisible does not check if the element is within the viewport + function isVisible(element: null | Element, parent: Element) { + if (!element) return false; + + const bounds = element.getBoundingClientRect(); + const parentBounds = parent.getBoundingClientRect(); + + return ( + bounds.top >= parentBounds.top && + bounds.left >= parentBounds.left && + bounds.right <= parentBounds.right && + bounds.bottom <= parentBounds.bottom + ); + } + + module('linking with hashes', function (_hooks) { + test('in-page-links can be scrolled to with native anchors', async function (assert) { + this.owner.register( + 'template:application', + + ); + + await visit('/'); + + const container = document.querySelector('#ember-testing-container'); + const first = find('#first'); + const second = find('#second'); + + debugAssert(`Expected all test elements to exist`, container && first && second); + + assert.true(isVisible(first, container), 'first header is visible'); + assert.false(isVisible(second, container), 'second header is not visible'); + assert.equal(location.hash, '', 'initially, has no hash'); + + await click('#second-link'); + + assert.false(isVisible(first, container), 'first header is not visible'); + assert.true(isVisible(second, container), 'second header is visible'); + assert.equal(location.hash, '#second', 'clicked hash appears in URL'); + + await click('#first-link'); + + assert.true(isVisible(first, container), 'first header is visible'); + assert.false(isVisible(second, container), 'second header is not visible'); + assert.equal(location.hash, '#first', 'clicked hash appears in URL'); + }); + + test('in-page-links can be scrolled to with custom links', async function (assert) { + class TestApplication extends Component { + handleClick = (event: MouseEvent) => { + event.preventDefault(); + + debugAssert( + `Expected event to be from an anchor tag`, + event.target instanceof HTMLAnchorElement + ); + + const [, hash] = event.target.href.split('#'); + + scrollToHash(hash!); + }; + + + } + + this.owner.register('template:application', TestApplication); + + await visit('/'); + + const container = document.querySelector('#ember-testing-container'); + const first = find('#first'); + const second = find('#second'); + + debugAssert(`Expected all test elements to exist`, container && first && second); + + assert.true(isVisible(first, container), 'first header is visible'); + assert.false(isVisible(second, container), 'second header is not visible'); + assert.equal(location.hash, '', 'initially, has no hash'); + + await click('#second-link'); + await scrollSettled(); + + assert.false(isVisible(first, container), 'first header is not visible'); + assert.true(isVisible(second, container), 'second header is visible'); + assert.equal(location.hash, '#second', 'clicked hash appears in URL'); + + await click('#first-link'); + await scrollSettled(); + + assert.true(isVisible(first, container), 'first header is visible'); + assert.false(isVisible(second, container), 'second header is not visible'); + assert.equal(location.hash, '#first', 'clicked hash appears in URL'); + }); + }); + + module('with transitions', function (hooks) { + setupRouter(hooks, { + map: function () { + this.route('foo'); + this.route('bar'); + }, + }); + + test('transitioning only via query params does not break things', async function (assert) { + class TestApplication extends Controller { + queryParams = ['test']; + test = false; + } + class Index extends Component { + @service declare router: RouterService; + + + } + + this.owner.register('controller:application', TestApplication); + this.owner.register( + 'template:application', + + ); + this.owner.register('template:index', Index); + + const router = this.owner.lookup('service:router'); + + await visit('/'); + assert.dom('out').hasText('qp:'); + + await click('#foo'); + assert.dom('out').hasText('qp: foo'); + + await click('#default'); + assert.dom('out').hasText('qp:'); + + router.transitionTo({ queryParams: { test: 'foo' } }); + await settled(); + assert.dom('out').hasText('qp: foo'); + + router.transitionTo({ queryParams: { test: false } }); + await settled(); + assert.dom('out').hasText('qp: false'); + }); + + test('cross-page-Llinks are properly scrolled to', async function (assert) { + this.owner.register( + 'template:foo', + + ); + + this.owner.register( + 'template:bar', + + ); + + const router = this.owner.lookup('service:router'); + const container = document.querySelector('#ember-testing-container'); + + debugAssert(`Expected all test elements to exist`, container); + + router.transitionTo('/foo'); + await uiSettled(this.owner); + + assert.true(isVisible(find('#foo-first'), container), 'first header is visible'); + assert.false(isVisible(find('#foo-second'), container), 'second header is not visible'); + assert.equal(location.hash, '', 'initially, has no hash'); + + router.transitionTo('/bar#bar-second'); + await uiSettled(this.owner); + await scrollSettled(); + + assert.false(isVisible(find('#bar-first'), container), 'first header is not visible'); + assert.true(isVisible(find('#bar-second'), container), 'second header is visible'); + assert.equal(location.hash, '#bar-second', 'clicked hash appears in URL'); + + router.transitionTo('/foo#foo-second'); + await uiSettled(this.owner); + await scrollSettled(); + + assert.false(isVisible(find('#foo-first'), container), 'first header is not visible'); + assert.true(isVisible(find('#foo-second'), container), 'second header is visible'); + assert.equal(location.hash, '#foo-second', 'clicked hash appears in URL'); + }); + }); + + // https://github.com/CrowdStrike/ember-url-hash-polyfill/issues/118 + test('transition to route with loading sub state is properly handled', async function (assert) { + this.owner.register( + 'template:application', + + ); + + this.owner.register('template:application-loading', ); + + class ApplicationRoute extends Route { + model() { + return new Promise(function (resolve) { + // Keep the timeout > to addon/index.ts "MAX_TIMEOUT" to make this test accurate + setTimeout(resolve, 4000); + }); + } + } + + this.owner.register('route:application', ApplicationRoute); + + await visit('/#second'); + await scrollSettled(); + + const container = document.querySelector('#ember-testing-container'); + const first = find('#first'); + const second = find('#second'); + + debugAssert(`Expected all test elements to exist`, container && first && second); + + await waitUntil(() => isVisible(second, container), { + timeoutMessage: 'second header is visible', + }); + + assert.equal(location.hash, '#second', 'hash appears in URL'); + }); +}); + +async function scrollSettled() { + // wait for previous stuff to finish + await settled(); + + const timeout = 200; // ms; + const start = new Date().getTime(); + + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, 1000)), + // scrollIntoView does not trigger scroll events + new Promise((resolve) => { + const interval = setInterval(() => { + const now = new Date().getTime(); + + if (now - start >= timeout) { + clearInterval(interval); + + return resolve(now); + } + }, 10); + }), + ]); + + await settled(); +}