Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions ember-primitives/src/polyfill/anchor-hash-targets.ts
Original file line number Diff line number Diff line change
@@ -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<Route['beforeModel']>[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<Owner, MutationObserver>();

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);
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion test-app/tests/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

<script type="module">
import { start } from "./test-helper";
import.meta.glob("./**/*.{js,ts,gjs,gts}", { eager: true });
import.meta.glob("./**/**/*-test.{js,ts,gjs,gts}", { eager: true });

start();
</script>
Expand Down
116 changes: 116 additions & 0 deletions test-app/tests/polyfill/-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<T>(maybeArray?: T | T[]): T[] {
if (Array.isArray(maybeArray)) {
return maybeArray;
}

if (!maybeArray) {
return [];
}

return [maybeArray];
}
Loading
Loading