rollthecloudinc / quell

Climate aware CMS breaking web apps free from carbon emissions.
https://demo.carbonfreed.app/pages/create-panel-page
GNU General Public License v3.0
14 stars 1 forks source link

Pre-rendered page flickering #278

Open ng-druid opened 2 years ago

ng-druid commented 2 years ago

Pre-rendered pages are flickering like they double load. I beleive this is caused by the redirects in the routing. So the question becomes is is there an alternate dynamic routing strategy that can be implemented without requiring redirects. The redirects are required because the catch all routes add new routes from a query.

there are also some acceptable work arounds for this depending on use case. I think one for static sites like roll the cloud could be to simply hard code the route inside the app module. In this way we would be ignoring the dynamic routing which isn’t necessarily needed for roll the cloud anyway.

A road block with this is that the app module routing for uses a guard to initiate a redirect when a path is matched as part of an alias plugin. Otherwise the route falls though to a page not found component.

I think the loading strategy

ng-druid commented 2 years ago

Stackoverflow post incase I'm censored by the authoritarians.

Describes the problem as it related to roll the cloud and druid core diagnoses of what I believe is the root cause.

I have an Angular 13 website that is pre-rendered. However, when users visit my site the page flickers. Can anyone assist to diagnose the cause and remedy of this problem.

I don't understand what more can be provided other than a physical link that demonstrates the problem and source code as a github repo.

This question with one line of code is allowed to be posted but I'm being censored for providing a demo.

https://stackoverflow.com/questions/49998277/angular-universal-flickering

I believe that I have identified the root cause of the problem. The problem is caused due to redirects that are occurring in the route resolution workflow.

In the app module there is this catch all path as a root path.

{ path: '**', component: CatchAllRouterComponent, canActivate: [ CatchAllGuard ] }

The CatchAllGaurd loads plugins and each plugin is responsible for altering the router with dynamic routes.

import { Inject, Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlMatcher, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { EntityServices, EntityCollectionService } from '@ngrx/data';
import { of, forkJoin , iif } from 'rxjs';
import { SITE_NAME } from '@ng-druid/utils';
import { map, switchMap, catchError, tap, filter, defaultIfEmpty } from 'rxjs/operators';
/*import { PanelPageListItem, PanelPage } from '@ng-druid/panels';
import { PanelPageRouterComponent } from '../components/panel-page-router/panel-page-router.component';
import { EditPanelPageComponent } from '../components/edit-panel-page/edit-panel-page.component';*/
//import * as qs from 'qs';
import { AliasPluginManager } from '../services/alias-plugin-manager.service';
import { AliasPlugin } from '../models/alias.models';

@Injectable()
export class CatchAllGuard implements CanActivate {

  routesLoaded = false;

  // panelPageListItemsService: EntityCollectionService<PanelPageListItem>;

  constructor(
    @Inject(SITE_NAME) private siteName: string,
    private router: Router,
    private apm: AliasPluginManager,
    es: EntityServices
  ) {
    // this.panelPageListItemsService = es.getEntityCollectionService('PanelPageListItem');
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<UrlTree | boolean> {
    console.log('catch all alias hit');
    return new Promise(res => {
      this.apm.getPlugins().pipe(
        switchMap(plugins => forkJoin(!this.routesLoaded ? Array.from(plugins).map(([_, p]) => p.loadingStrategy.load()) : []).pipe(
          defaultIfEmpty(undefined)
        )),
        tap(() => this.routesLoaded = true),
        switchMap(() => this.apm.getPlugins()),
        switchMap(plugins => forkJoin(Array.from(plugins).map(([_, p]) => p.matchingStrategy.match(state).pipe(
          map(m => [p, m])
        ))))
      ).subscribe((pp: Array<[AliasPlugin<string>, boolean]>) => {
        console.log(`routes loaded: ${this.routesLoaded ? 'y' : 'n'}`);
        const matchedPlugin = pp.map(([p, m], _) => m ? p : undefined).find(p => p !== undefined);
        if (matchedPlugin !== undefined) {
          console.log(`alias gaurd state: ${state.url}`);
          const urlTree = this.router.parseUrl(state.url);
          // matchedPlugin.redirectHandler.redirect(route, state);
          // res(false);
          res(urlTree);
        } else {
          res(true);
        }
      });
    });
  }
}

An example of a matching strategy that loads routes from open search.

import { Observable } from "rxjs";
import { AliasLoadingStrategy } from '@ng-druid/alias';
import { map, tap } from "rxjs/operators";
import { PanelPage } from '@ng-druid/panels';
import { EntityServices } from "@ngrx/data";
import { Router, UrlMatcher, UrlSegment } from '@angular/router';

export class PagealiasLoadingStrategy implements AliasLoadingStrategy {
  routesLoaded = false;
  get panelPageListItemsService() {
    return this.es.getEntityCollectionService('PanelPageListItem');
  }
  constructor(
    private siteName: string,
    private es: EntityServices,
    private router: Router
  ) {
  }
  isLoaded() {
    return this.routesLoaded;
  }
  load(): Observable<boolean> {
    return this.panelPageListItemsService.getWithQuery(`site=${encodeURIComponent(`{"term":{"site.keyword":{"value":"${this.siteName}"}}}`)}&path=${encodeURIComponent(`{"wildcard":{"path.keyword":{"value":"*"}}}`)}`).pipe(
      map(pp => pp.filter(p => p.path !== undefined && p.path !== '')),
      map(pp => pp.map(o => new PanelPage(o)).sort((a, b) => {
        if(a.path.split('/').length === b.path.split('/').length) {
          return a.path.split('/')[a.path.split('/').length - 1] > b.path.split('/')[b.path.split('/').length - 1] ? -1 : 1;
        }
        return a.path.split('/').length > b.path.split('/').length ? -1 : 1;
      })),
      //tap(pp => pp.sort((a, b) => a.path.length > b.path.length ? 1 : -1)),
      tap(pp => {
        // const target = this.router.config.find(r => r.path === '');

        // const matchers = pp.map(p => [ this.createEditMatcher(p), this.createMatcher(p) ]).reduce<Array<UrlMatcher>>((p, c) => [ ...p, ...c ], []);
        const paths = pp.map(p => p.path);
        pp.forEach(p => console.log(`path: ${p.path}`));

        this.router.config.unshift({ matcher: this.createPageMatcher(paths), loadChildren: () => {
          console.log('matched panel page route');
          return import('@ng-druid/pages').then(m => m.PagesModule).then(m => {
            console.log('router info', this.router);
            return m;
          });
        } });
        this.routesLoaded = true;
      }),
      tap(() => console.log('panels routes loaded')),
      map(() => true)
    );
  }

  createPageMatcher(paths: Array<string>): UrlMatcher  {
    return (url: UrlSegment[]) => {

      console.log('attempt match: ' + url.join('/'));

      for (let i = 0; i < paths.length; i++) {
        if(('/' + url.map(u => u.path).join('/')).indexOf(paths[i]) === 0) {
          return { consumed: [], posParams: {} };
        }
      }

      if (url.length > 0 && url[0].path === 'pages') {
        console.log('matched page!');
        return {
          consumed: url.slice(0, 1),
          posParams: {}
        };
      } else {
        return null;
      }

    };
  }

}

Inside of the lazy loaded module in this case @ng-druid/pages there is another catch all route.

{ path: '**', component: CatchAllRouterComponent, canActivate: [ CatchAllGuard ] }

All dynamic pages fall through to that route and are handled by the CatchAllGuard which has another redirect once the dynamic routes are loaded into the router.

import { Inject, Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlMatcher, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { EntityServices, EntityCollectionService } from '@ngrx/data';
import { of, forkJoin , iif } from 'rxjs';
import { SITE_NAME } from '@ng-druid/utils';
import { map, switchMap, catchError, tap, filter } from 'rxjs/operators';
import { PanelPageListItem, PanelPage } from '@ng-druid/panels';
import { PanelPageRouterComponent } from '../components/panel-page-router/panel-page-router.component';
import { EditPanelPageComponent } from '../components/edit-panel-page/edit-panel-page.component';
import * as qs from 'qs';

@Injectable()
export class CatchAllGuard implements CanActivate {

  routesLoaded = false;

  panelPageListItemsService: EntityCollectionService<PanelPageListItem>;

  constructor(
    @Inject(SITE_NAME) private siteName: string,
    private router: Router,
    es: EntityServices
  ) {
    this.panelPageListItemsService = es.getEntityCollectionService('PanelPageListItem');
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<UrlTree | boolean> {
    console.log('pages can activate top');
    return new Promise(res => {
      let url = state.url;
      if (url.indexOf('?') !== -1) {
        url = state.url.substr(0, url.indexOf('?'));
      }
      const matchPathQuery = 'path=' + url.substr(1).split('/').reduce<Array<string>>((p, c, i) => [ ...p, i === 0 ?  `/${c}`  :  `${p[i-1]}/${c}` ], []).map(p => this.encodePathComponent(p)).join('&path=') + `&site=${encodeURIComponent(`{"term":{"site.keyword":{"value":"${this.siteName}"}}}`)}`;
      forkJoin([
        iif(
          () => !this.routesLoaded,
          this.panelPageListItemsService.getWithQuery(`site=${encodeURIComponent(`{"term":{"site.keyword":{"value":"${this.siteName}"}}}`)}&path={"wildcard":{"path.keyword":{"value":"*"}}}`).pipe(
            tap(() => console.log('loaded page list items')),
            map(pp => pp.filter(p => p.path !== undefined && p.path !== '')),
            map(pp => pp.map(o => new PanelPage(o)).sort((a, b) => {
              if(a.path.split('/').length === b.path.split('/').length) {
                return a.path.split('/')[a.path.split('/').length - 1] > b.path.split('/')[b.path.split('/').length - 1] ? -1 : 1;
              }
              return a.path.split('/').length > b.path.split('/').length ? -1 : 1;
            })),
            tap(pp => pp.sort((a, b) => a.path.length > b.path.length ? 1 : -1)),
            tap(pp => {
              // const target = (this.router.config[0] as any)._loadedConfig.routes;
              // @temp: experimental with hard-coded dynamic routes.
              const target = (this.router.config.find(r => r.path === 'just-a-lonely-snippet-v1') as any)._loadedConfig.routes;
              pp.forEach(p => {
                target.unshift({ matcher: this.createMatcher(p), component: PanelPageRouterComponent, data: { panelPageListItem: p } });
                target.unshift({ matcher: this.createEditMatcher(p), component: EditPanelPageComponent });
                console.log(`panels matcher: ${p.path}`);
              });
              this.routesLoaded = true;
            }),
            map(() => [])
          ),
          of([])
        ),
        this.panelPageListItemsService.getWithQuery(matchPathQuery).pipe(
          catchError(e => {
            return of([]);
          }),
          tap(() => console.log('loaded specific matched')),
          map(pages => pages.reduce((p, c) => p === undefined ? c : p.path.split('/').length < c.path.split('/').length ? c : p , undefined)),
          map(panelPage => {
            const argPath = state.url.substr(1).split('/').slice(panelPage.path.split('/').length - 1).join('/');
            return [panelPage, argPath];
          })
        )
      ]).pipe(
        map(([pp, [panelPage, argPath]]) => [panelPage, argPath])
      ).subscribe(([panelPage, argPath]) => {
        const targetUrl = `${panelPage.path}${argPath === '' ? '' : `/${argPath}`}?${qs.stringify(route.queryParams)}`;
        const urlTree = this.router.parseUrl(targetUrl);
        console.log(`panels garud navigate: ${panelPage.path}${argPath === '' ? '' : `/${argPath}`}?${qs.stringify(route.queryParams)}`);
        // this.router.navigateByUrl(`${panelPage.path}${argPath === '' ? '' : `/${argPath}`}?${qs.stringify(route.queryParams)}`, /* Removed unsupported properties by Angular migration: queryParams, fragment. */ {});
        // res(true);
        res(urlTree);
      });
    });
  }

  createMatcher(panelPage: PanelPage): UrlMatcher {
    return (url: UrlSegment[]) => {
      if(('/' + url.map(u => u.path).join('/')).indexOf(panelPage.path) === 0) {
        const pathLen = panelPage.path.substr(1).split('/').length;
        return {
          consumed: url,
          posParams: url.reduce<{}>((p, c, index) => {
            if(index === 0) {
              return { ...p, panelPageId: new UrlSegment(panelPage.id , {}) }
            } else if(index > pathLen - 1) {
              return { ...p, [`arg${index - pathLen}`]: new UrlSegment(c.path, {}) };
            } else {
              return { ...p };
            }
          }, {})
        };
      } else {
        return null;
      }
    };
  }

  createEditMatcher(panelPage: PanelPage): UrlMatcher {
    return (url: UrlSegment[]) => {
      if(('/' + url.map(u => u.path).join('/')).indexOf(panelPage.path) === 0 && url.map(u => u.path).join('/').indexOf('/manage') > -1) {
        const pathLen = panelPage.path.substr(1).split('/').length;
        return {
          consumed: url,
          posParams: url.reduce<{}>((p, c, index) => {
            if(index === 0) {
              return { ...p, panelPageId: new UrlSegment(panelPage.id , {}) }
            } else {
              return { ...p };
            }
          }, {})
        };
      } else {
        return null;
      }
    };
  }

  encodePathComponent(v: string): string {
    return `{"term":{"path.keyword":{"value":"${v}"}}}`;
  }

}

The problem is the series of redirects used to handle the dynamic routes. This methodology has worked fine up until this point. However, with server-side rendering it seems to be causing the issues described.

I think what I really need is a way to have dynamic routes loaded from a remote source without using this redirect pattern.

ng-druid commented 2 years ago

This is on hold for the moment. Too much time has been sucked up by this problem. Need to take a break and clear the mind.