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

Experiment: Page enhancement allowing head elements to be added for a given page and automatically removed when routing away #62

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
60 changes: 44 additions & 16 deletions src/core/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export abstract class Component {
return this.windowDocument.getElementById(this.Id);
}
protected RootElementDisplayStyle: 'block' | 'inline-block' = 'inline-block';
protected HeadElements: () => Promise<Omit<Jsx, 'children'>[]> = async () => [];

private headElements: HTMLElement[] = [];
private ids = new Map<string, string>();
private mutationObserver: MutationObserver;
private reRenderQueued = false;
Expand All @@ -29,13 +31,7 @@ export abstract class Component {
) {
this.Id = `fy-${Guid.NewGuid()}`;
Component.IssuedIds.push(this.Id);

this.mutationObserver = new MutationObserver(() => {
if (!this.Element) {
this.mutationObserver.disconnect();
this.Disconnected();
}
});
this.mutationObserver = new MutationObserver(this.disconnect);
}

/**
Expand All @@ -47,23 +43,31 @@ export abstract class Component {
* Returns the render-able html from the component's template
* @param route
*/
public async Render(route?: Route, includeWrapper = true): Promise<string> {
Asap(() => {
if (this.Element && this.Element.parentElement) {
this.mutationObserver.observe(this.Element.parentElement, { childList: true });
}
});
public async Render(
route = this.App.Router.Route.CurrentIssue,
includeWrapper = true
): Promise<string> {
if (!this.Element) {
Asap(() => {
if (this.Element) {
this.mutationObserver.observe(this.App.Main, {
childList: true,
subtree: true
});
}
});
}

await this.setHeadElements();
const content = await this.getOuterHtml(await this.Template(route));

return includeWrapper ? /*html*/ `<div id="${this.Id}" style="display: ${this.RootElementDisplayStyle};">${content}</div>` : content;
}

/**
* Replace the currently rendered component's innerHtml with a fresh version then rerun behavior
* @param route
*/
public async ReRender(route?: Route): Promise<void> {
public async ReRender(): Promise<void> {
if (!this.reRenderQueued) {
this.reRenderQueued = true;
Asap(async () => {
Expand All @@ -75,7 +79,7 @@ export abstract class Component {
e.removeAttribute('ref');
});

const newRender = await this.Render(route, false);
const newRender = await this.Render(undefined, false);
const newElement = document.createElement('div');
newElement.innerHTML = newRender;

Expand Down Expand Up @@ -113,4 +117,28 @@ export abstract class Component {
return await JsxRenderer.RenderJsx(html);
}
}

private setHeadElements = async () => {
(await this.HeadElements()).forEach(e => {
const newElement = this.windowDocument.createElement(e.nodeName);
for (const key in e.attributes) {
const value = e.attributes[key];
newElement.setAttribute(key, value);
}
const selector = `${newElement.tagName.toLowerCase()}${Array.from(newElement.attributes)
.map(a => `[${a.name}="${a.value}"]`).join('')}`;
if (!this.windowDocument.head.querySelector(selector)) {
this.headElements.push(newElement);
this.windowDocument.head.appendChild(newElement);
}
});
};

private disconnect = () => {
if (!this.Element) {
this.mutationObserver.disconnect();
this.headElements.forEach(e => e.remove());
this.Disconnected();
}
};
}
2 changes: 1 addition & 1 deletion src/core/decorators/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function definePropertyForStateInStore(target: Component, key: string, storeType

if (!component[subKey]) {
const subId = store.ObservableAt(key).Subscribe(() => {
component.ReRender(component.App.Router.Route.CurrentIssue);
component.ReRender();
});
component[subKey] = subId;
}
Expand Down
5 changes: 3 additions & 2 deletions src/core/jsx.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Guid } from 'tsbase/System/Guid';
import { Strings } from 'tsbase/System/Strings';
import { Command } from 'tsbase/Patterns/CommandQuery/Command';
import { Router } from './services/router/router';
import { Asap } from '../utilities/asap';
import { Component } from './component';
import { EventTypes } from './eventTypes';
import { DomEvents } from './domEvents';
import { Asap } from '../utilities/asap';

export type Jsx = {
attributes: Record<string, string>,
Expand All @@ -18,7 +19,7 @@ export function ParseJsx(nodeName, attributes, ...children): Promise<string> | J
if (typeof nodeName === 'function' && nodeName.constructor) {
const instance = new nodeName(attributes ?? undefined, children.length > 0 ? children : undefined) as Component;
return new Promise(async (resolve) => {
const result = await instance.Render();
const result = await instance.Render(Router.Instance().Route.CurrentIssue);
resolve(result);
});
}
Expand Down
1 change: 0 additions & 1 deletion src/core/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export abstract class Page extends Component {

private async renderPageInMain(route: Route): Promise<void> {
await this.App.UpdateLayout(this.Layout);

this.seoService.SetDefaultTags(this.Title, this.Description, this.ImageUrl);

const markup = await this.Render(route);
Expand Down
9 changes: 6 additions & 3 deletions src/core/tests/component.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ describe('Component', () => {
mockComponentState.Setup(s => s.ObservableAt(Strings.Empty), fakeComponentStateObservable);
mockComponentState.Setup(s => s.GetState(Strings.Empty), Strings.Empty);
mockComponentState.Setup(s => s.SetState(Strings.Empty, Strings.Empty));
const fakeMain = document.createElement('main');
mockApp.Setup(a => a.Main, fakeMain);

classUnderTest = new FakeComponent(mockDocument.Object, mockApp.Object);
classUnderTest.SetState(mockComponentState.Object);
Expand Down Expand Up @@ -121,8 +123,9 @@ describe('Component', () => {
});

it('should observe mutations on parent element and call disconnected when element no longer exists in the dom', async () => {
classUnderTest = new FakeComponent();
document.body.innerHTML = await classUnderTest.Render();
classUnderTest = new FakeComponent(undefined, mockApp.Object);
mockApp.Object.Main.innerHTML = await classUnderTest.Render();
document.body.appendChild(mockApp.Object.Main);

Asap(() => {
classUnderTest.Element?.remove();
Expand All @@ -134,7 +137,7 @@ describe('Component', () => {
});

it('should observe mutations on parent element BUT NOT call disconnected when the component is still in the dom', async () => {
classUnderTest = new FakeComponent();
classUnderTest = new FakeComponent(undefined, mockApp.Object);
document.body.innerHTML = await classUnderTest.Render();

Asap(() => {
Expand Down
69 changes: 59 additions & 10 deletions src/core/tests/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,41 @@ import { App } from '../app';
import { Page } from '../page';
import { IRouter, ISeoService, Route } from '../services/module';

const id = '12345';

class FakePage extends Page {
Template = async () => <div>test</div>;
Route = async () => this.routeMatches;

constructor(
private routeMatches: boolean,
public routeMatches: boolean,
seoService?: ISeoService,
app?: App,
windowDocument?: Document
) {
super(seoService, app, windowDocument);
this.Id = id;
}
}

class FakePageWithHeadElements extends FakePage {
HeadElements = async () => [
<script src="fake" />,
<style link="fake" />
];
}

describe('Page', () => {
let classUnderTest: FakePage;
const mockDocument = new Mock<Document>();
let mockDocument: Mock<Document>;
const mockRouter = new Mock<IRouter>();
const mockApp = new Mock<App>();
const mockSeoService = new Mock<ISeoService>();
let fakeRouteObservable: AsyncObservable<Route>;
let fakeRoute: Route;
const id = '12345';

beforeEach(() => {
const fakeElement = document.createElement('div');
mockDocument = new Mock<Document>();
fakeRouteObservable = new AsyncObservable<Route>();
fakeRoute = {
hashParams: [],
Expand All @@ -41,15 +50,16 @@ describe('Page', () => {
queryParams: new Map<string, string>(),
routeParams: []
};
mockDocument.Setup(d => d.getElementById(''), fakeElement);
mockRouter.Setup(r => r.Route, fakeRouteObservable);
mockRouter.Setup(r => r.RouteHandled, id);
mockApp.Setup(a => a.Router, mockRouter.Object);
mockSeoService.Setup(s => s.SetDefaultTags());
mockApp.Setup(a => a.UpdateLayout());
mockRouter.Setup(r => r.UseClientRouting());
mockDocument.Setup(d => d.createElement('script'), document.createElement('script'));
mockDocument.Setup(d => d.createElement('style'), document.createElement('style'));

classUnderTest = new FakePage(true, mockSeoService.Object, mockApp.Object, mockDocument.Object);
classUnderTest.Id = id;
classUnderTest = new FakePage(false, mockSeoService.Object, mockApp.Object, mockDocument.Object);
});

it('should construct', () => {
Expand All @@ -71,17 +81,20 @@ describe('Page', () => {
fakeRoute.href = 'http://localhost/new-path';
const fakeHead = document.createElement('head');
fakeHead.innerHTML = 'test';
const fakeElement = document.createElement('div');
const fakeMain = document.createElement('main');
const fakeElement = {
parentElement: fakeMain
};
mockDocument.SetupSequence([
[d => d.getElementById(id), null],
[d => d.getElementById(id), null],
[d => d.getElementById(id), fakeElement],
[d => d.getElementById(id), fakeElement]
]);
mockDocument.Setup(d => d.head, fakeHead);
const fakeMain = document.createElement('main');
mockApp.Setup(a => a.Main, fakeMain);
mockRouter.Setup(r => r.RouteHandled, Strings.Empty);
classUnderTest = new FakePage(true, mockSeoService.Object, mockApp.Object, mockDocument.Object);
classUnderTest = new FakePageWithHeadElements(true, mockSeoService.Object, mockApp.Object, mockDocument.Object);

await fakeRouteObservable.Publish(fakeRoute);

Expand All @@ -93,6 +106,42 @@ describe('Page', () => {
expect(fakeMain.innerHTML).toContain('<div id=\"12345\" style=\"display: block;\"><div>test</div></div>');
expect(fakeMain.innerHTML).toContain('<!-- fyord-hybrid-render -->');
});

await TestHelpers.Expect(
() => fakeHead.innerHTML.includes('script') && fakeHead.innerHTML,
m => m.toContain('<script src=\"fake\"></script><style link=\"fake\"></style>'));
});

it('should remove head elements when routing away from page that rendered them', async () => {
fakeRoute.path = '/new-path';
fakeRoute.href = 'http://localhost/new-path';
const fakeHead = document.createElement('head');
fakeHead.innerHTML = 'test';
const fakeMain = document.createElement('main');
const mockElement = new Mock<HTMLElement>();
mockElement.Setup(e => e.parentElement, fakeMain);
mockDocument.SetupSequence([
[d => d.getElementById(id), null]
]);
mockDocument.Setup(d => d.head, fakeHead);
mockApp.Setup(a => a.Main, fakeMain);
mockRouter.Setup(r => r.RouteHandled, Strings.Empty);
classUnderTest = new FakePageWithHeadElements(true, mockSeoService.Object, mockApp.Object, mockDocument.Object);

await fakeRouteObservable.Publish(fakeRoute);

await TestHelpers.Expect(
() => fakeHead.innerHTML.includes('script') && fakeHead.innerHTML,
m => m.toContain('<script src=\"fake\"></script><style link=\"fake\"></style>'));

classUnderTest.routeMatches = false;
mockRouter.Object.RouteHandled = '';
fakeRouteObservable.Publish(fakeRoute);
classUnderTest['disconnect']();

await TestHelpers.Expect(
() => !fakeHead.innerHTML.includes('script') && fakeHead.innerHTML,
m => m.toEqual('test'));
});

it('should not re render if the component is already rendered at the same path', async () => {
Expand Down