Skip to content

Commit

Permalink
feat: initial implementation
Browse files Browse the repository at this point in the history
BREAKING CHANGE: initial release prep
(actually implement the behavior)
  • Loading branch information
NullVoxPopuli committed Apr 22, 2021
1 parent c498241 commit 86cad0c
Show file tree
Hide file tree
Showing 16 changed files with 740 additions and 141 deletions.
10 changes: 3 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,12 @@ jobs:
fail-fast: true
matrix:
ember-try-scenario:
- ember-lts-3.8
- ember-lts-3.12
- ember-lts-3.16
- ember-lts-3.20
- ember-lts-3.24
- ember-3.25
- ember-3.26
- ember-release
- ember-beta
- ember-canary
# - embroider
# - ember-classic
- embroider
steps:
- uses: actions/checkout@v2
- uses: volta-cli/action@v1
Expand Down
59 changes: 42 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,63 @@
ember-url-hash-polyfill
==============================================================================

[Short description of the addon.]
Navigating to URLs with `#hash-targets` in them is not supported by
most single-page-app frameworks due to the async rendering nature of
modern web apps -- the browser can't scroll to a `#hash-target` on
page load / transition because the element hasn't rendered yet.
There is an issue about this for Ember
[here on the RFCs repo](https://github.com/emberjs/rfcs/issues/709).

This addon provides a way to support the behavior that is in normally
native to browsers where an anchor tag with `href="#some-id-or-name"`
would scroll down the page when clicked.

Compatibility
------------------------------------------------------------------------------
## Installation

* Ember.js v3.16 or above
* Ember CLI v2.13 or above
* Node.js v10 or above
```
yarn add ember-url-hash-polyfill
# or
npm install ember-url-hash-polyfill
# or
ember install ember-url-hash-polyfill
```

## Compatibility

Installation
------------------------------------------------------------------------------
* Ember.js v3.25 or above
* Node.js v12 or above

```
ember install ember-url-hash-polyfill
## Usage

To handle `/some-url/#hash-targets` on page load and after normal route transitions,
```diff
// app/router.js

import { withHashSupport } from 'ember-url-hash-polyfill';

+ @withHashSupport
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
```

Additionally, there is a `scrollToHash` helper if manual invocation is desired.

Usage
------------------------------------------------------------------------------
```js
import { scrollToHash } from 'ember-url-hash-polyfill';

[Longer description of how to use the addon in apps.]
// ...

scrollToHash('some-element-id-or-name');
```


Contributing
------------------------------------------------------------------------------
## Contributing

See the [Contributing](CONTRIBUTING.md) guide for details.


License
------------------------------------------------------------------------------
## License

This project is licensed under the [MIT License](LICENSE.md).
Empty file removed addon/.gitkeep
Empty file.
184 changes: 184 additions & 0 deletions addon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { getOwner } from '@ember/application';
import { warn } from '@ember/debug';
import { isDestroyed, isDestroying, registerDestructor } from '@ember/destroyable';
import { schedule } from '@ember/runloop';
import { waitForPromise } from '@ember/test-waiters';

import type ApplicationInstance from '@ember/application/instance';
import type { Route } from '@ember/routing';
import type EmberRouter from '@ember/routing/router';
import type RouterService from '@ember/routing/router-service';

type Transition = Parameters<Route['beforeModel']>[0];
type TransitionWithPrivateAPIs = Transition & {
intent: {
url: string;
};
};

export function withHashSupport(AppRouter: typeof EmberRouter) {
return class RouterWithHashSupport extends AppRouter {
constructor(...args: RouterArgs) {
super(...args);

setupHashSupport(this);
}
};
}

export function scrollToHash(hash: string) {
let selector = `[name="${hash}"]`;
let 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) {
let withoutHash = location.href.split('#')[0];
let nextUrl = `${withoutHash}#${hash}`;
// most browsers ignore the title param of pushState
let titleWithoutHash = document.title.split(' | #')[0];
let nextTitle = `${titleWithoutHash} | #${hash}`;

history.pushState({}, nextTitle, nextUrl);
document.title = nextTitle;
}
}

async function setupHashSupport(router: EmberRouter) {
let initialURL: string | undefined;
let owner = getOwner(router) as ApplicationInstance;

await new Promise((resolve) => {
let interval = setInterval(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let { currentURL } = router as any; /* Private API */

if (currentURL) {
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(() => {
eventuallyTryScrollingTo(owner, initialURL);
});

let routerService = owner.lookup('service:router') as RouterService;

function handleHashIntent(transition: TransitionWithPrivateAPIs) {
let { url } = transition.intent;

eventuallyTryScrollingTo(owner, url);
}

routerService.on('routeDidChange', handleHashIntent);

registerDestructor(router, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(routerService as any) /* type def missing "off" */
.off('routeDidChange', handleHashIntent);
});
}

const CACHE = new WeakMap<ApplicationInstance, MutationObserver>();

async function eventuallyTryScrollingTo(owner: ApplicationInstance, url?: string) {
// Prevent quick / rapid transitions from continuing to observer beyond their URL-scope
CACHE.get(owner)?.disconnect();

if (!url) return;

let [, 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
export async function uiSettled(owner: ApplicationInstance) {
let timeStarted = new Date().getTime();
let lastMutationAt = Infinity;
let totalTimeWaited = 0;

let 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;

let 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);
});
}
34 changes: 8 additions & 26 deletions config/ember-try.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ module.exports = async function () {
return {
scenarios: [
{
name: 'ember-lts-3.16',
name: 'ember-3.25',
npm: {
devDependencies: {
'ember-source': '~3.16.0',
'ember-source': '~3.25.0',
},
},
},
{
name: 'ember-lts-3.20',
name: 'ember-3.20',
npm: {
devDependencies: {
'ember-source': '~3.20.5',
'ember-source': '~3.26.0',
},
},
},
Expand Down Expand Up @@ -46,30 +46,12 @@ module.exports = async function () {
},
},
{
name: 'ember-default-with-jquery',
env: {
EMBER_OPTIONAL_FEATURES: JSON.stringify({
'jquery-integration': true,
}),
},
name: 'embroider',
npm: {
devDependencies: {
'@ember/jquery': '^1.1.0',
},
},
},
{
name: 'ember-classic',
env: {
EMBER_OPTIONAL_FEATURES: JSON.stringify({
'application-template-wrapper': true,
'default-async-observers': false,
'template-only-glimmer-components': false,
}),
},
npm: {
ember: {
edition: 'classic',
'@embroider/core': '*',
'@embroider/webpack': '*',
'@embroider/compat': '*',
},
},
},
Expand Down
11 changes: 11 additions & 0 deletions ember-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,16 @@ module.exports = function (defaults) {
behave. You most likely want to be modifying `./index.js` or app's build file
*/

if ('@embroider/webpack' in app.dependencies()) {
const { Webpack } = require('@embroider/webpack'); // eslint-disable-line
return require('@embroider/compat') // eslint-disable-line
.compatBuild(app, Webpack, {
staticAddonTestSupportTrees: true,
staticAddonTrees: true,
staticHelpers: true,
staticComponents: true,
});
}

return app.toTree();
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"dependencies": {
"ember-cli-babel": "^7.23.1",
"ember-cli-htmlbars": "^5.3.2",
"ember-cli-typescript": "^4.1.0"
"ember-cli-typescript": "^4.1.0",
"ember-test-waiters": "^2.1.3"
},
"devDependencies": {
"@commitlint/cli": "^11.0.0",
Expand Down
2 changes: 2 additions & 0 deletions tests/dummy/app/router.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import EmberRouter from '@ember/routing/router';

import config from 'dummy/config/environment';
import { withHashSupport } from 'ember-url-hash-polyfill';

@withHashSupport
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
Expand Down
Empty file removed tests/helpers/.gitkeep
Empty file.
Loading

0 comments on commit 86cad0c

Please sign in to comment.