2

Hello fellow problem solvers! I'm hoping to learn something from you. I ran into a challenge that could probably teach me more about Angular, and I would appreciate any input anyone might have.

Background

My company shares a common footer among many web sites, and we lazy-load that into each site using JSONP. The footer content is managed by a system that generates static HTML, for high performance, low costs, and high uptime. When we lazy-load that footer into pages, we cannot control the name of the callback function call included in the response.

Our footer might look something like this footer.json file:

    footerCallback(["<footer>FROM THE FOOTER</footer>"]);

We lazy-load that into HTML pages with simple pure-Javascript code like this:

    <div id="footer"></div>
    <script>
      function footerCallback(json_data){
        document.getElementById('footer').outerHTML = json_data[0];
      }
      window.onload = function() {
        var script = document.createElement('script');
        script.src = 'assets/static-footer.json'
        script.async = true;
        document.getElementsByTagName('head')[0].appendChild(script);
      }
    </script>

I posted a working demo on GitHub, here. Unfortunately I couldn't post it on Stackblitz to make it easier to run, since it uses JSONP requests. This version includes the shared footer in exactly the way that I described above, right here.

Challenge

We need to include that same central footer in Angluar apps, so I'm trying to create a footer component that will insert that lazy-loaded JSONP footer into an Angular app.

Approach

I created a footer component and a footer service, and I'm using HttpClientJsonpModule to do a JSONP request. That part works. The service sends the JSONP request and I can see the response in my inspector in Chrome.

Problem

The part that does not work is the callback. I can't remove the original global callback function from the main index.html because I don't know how to replace it with a function that the JSONP response can trigger. I was able to move the callback function into the component, but only by continuing to define it globally:

    function footerCallback(json_data){
      document.getElementById('footer').outerHTML = json_data[0];
    }
    const _global = (window) as any
    _global.footerCallback = footerCallback

Angular expects to be able to tell the JSONP server the name of the callback function to include in the JSONP response. But there is no server, it's just a static file.

Angular is flexible enough that I can tell it the URL parameter to use to specify that callback function. If I specify "callback" as the callback function name with this.http.jsonp(this.footerURL,'callback') then it will issue a request like this:

    https://...footer.min.json?callback=ng_jsonp_callback_0

That's a nice feature and all, but it's the ng_jsonp_callback_0 that I need to customize, not the URL parameter. Because the server cannot adjust its response to include a reference to the function ng_jsonp_callback_0. Because there is no server, it's just a static file.

Ugly solution

My workaround was to define the callback function in the global scope. That way, I can support whatever callback function names the API or static file requires.

This at least enables me to encapsulate everything related to the lazy-loading footer into a footer component with its own service. But I feel like I'm not really doing this in the Angular Way, if I'm polluting the global scope.

Question

Is there a way for me to manually specify the name of that callback function myself, rather than letting Angular pick the name ng_jsonp_callback_0 or whatever?

If not, then is there some other elegant way to handle this? Other than using a global callback function?

2 Answers 2

4

Well..the first thing is that Angular has hardcoded the callback, if you check the source code you will see something like this:

/**
     * Get the name of the next callback method, by incrementing the global `nextRequestId`.
     */
    nextCallback() {
        return `ng_jsonp_callback_${nextRequestId++}`;
    }

So when the ng_jsonp_callback_ is not called, Angular throws an error:

 this.resolvedPromise.then(() => {
                    // Cleanup the page.
                    cleanup();
                    // Check whether the response callback has run.
                    if (!finished) {
                        // It hasn't, something went wrong with the request. Return an error via
                        // the Observable error path. All JSONP errors have status 0.
                        observer.error(new HttpErrorResponse({
                            url,
                            status: 0,
                            statusText: 'JSONP Error',
                            error: new Error(JSONP_ERR_NO_CALLBACK),
                        }));
                        return;
                    }
                    // Success. body either contains the response body or null if none was
                    // returned.
                    observer.next(new HttpResponse({
                        body,
                        status: 200,
                        statusText: 'OK',
                        url,
                    }));
                    // Complete the stream, the response is over.
                    observer.complete();
                });

Angular is just loading the url with the callback that they define and uses the window object or an empty object, as you can see in the source code:

/**
 * Factory function that determines where to store JSONP callbacks.
 *
 * Ordinarily JSONP callbacks are stored on the `window` object, but this may not exist
 * in test environments. In that case, callbacks are stored on an anonymous object instead.
 *
 *
 */
export function jsonpCallbackContext() {
    if (typeof window === 'object') {
        return window;
    }
    return {};
}

Now that we know this, we will do the following:

  1. Invoke the ng_jsonp_callback_ so we can skip the error and subscribe to the response. We need to insert the script for this.
  2. Use Renderer2 to manipulate the DOM.
  3. Inject ElementRef in the component
  4. Remove the content in the html we dont need it.
  5. Create and insert an element with the json_data into the elementRef of the component

Now in the component do the following:

export class FooterComponent implements AfterViewInit {

  // Steps 2 and 3
  constructor(private footerService: FooterService,
    private elementRef: ElementRef,
    private renderer: Renderer2) { }

  ngAfterViewInit() {
    this.addScript();
    this.footerService.getFooter().subscribe((data: string[]) => { this.footerCallback(data) });
  }
 
 // step 1
  private addScript() {
    // Wrap the ng_jsonp_callback_0 with your footerCallback 
    const scriptSrc = `window.footerCallback = function(json_data) {window.ng_jsonp_callback_0(json_data);}`;
    const scriptElement: HTMLScriptElement = this.renderer.createElement('script');
    scriptElement.innerHTML = scriptSrc;
    this.renderer.appendChild(this.elementRef.nativeElement, scriptElement);
  }

  // Insert a new Element with the json_data
  private footerCallback(json_data: string[]) {
    const footerElement: HTMLElement = this.renderer.createElement('div');
    footerElement.innerHTML = json_data[0];
    this.renderer.appendChild(this.elementRef.nativeElement, footerElement);
  }
}

That is it, hope it helps.

EDIT Don't forget to do cleanup.

Sign up to request clarification or add additional context in comments.

8 Comments

Oh my gosh, Oscar, you're my hero! Thank you so much! I have learned a lot already just from reading this, even without having a chance to try any of it yet. I won't have a chance to work with code until tomorrow but I'll get back to you ASAP. Thank you so much for sharing your knowledge. I had a working solution but it was nasty and I was hoping to learn more, and wow, yeah. There are a couple of new things here for me to dig into. And I spent an hour today puzzling over that subscribe() handler but you made it so clear. Thank you, I really appreciate this.
Oscar, your code works beautifully. It works in Node also! My previous solution reqruied some ugly stuff to render in Angular Universal. Thank you very much for advancing my knowledge. I had a working footer but I wanted to learn more about Angular, and you helped me a lot by pointing me toward Renderer2. I'm a veteran developer and architect but I'm new to Angular, and that helped me a lot. I hope that this can help someone else in the future, too.
To clarify: When you said, "Don't forget to do cleanup", were you referring to removing the callback script after the footer has been loaded?
Oh and PS: I wish that Angular's support for JSONP were more flexible. It's a pain to use Angular with static JSONP responses if Angular is assuming that the HTTP server is dynamic and will respect Angular telling it what name to use for a callback function. It's good to have a nice workaround though. Maybe I'll look back on this a few years from now in wonder at the crazy things we had to do before Angular's JSONP support became more flexible.
@RyanPorter I'm glad to hear that, and yes you should remove the script after the callback has been invoked and also remove the window.footerCallback.
|
0

I'm adding another answer for the benefit of anyone who stumbles onto this through search terms:

Oscar Ludick's answer to the question is correct. It helped me a lot. But the question is wrong.

If you're using Angular, and if you want to improve initial rendering times by lazy-loading HTML into your page that you know will be below the fold (like, for example, a page footer) then you do not want to set up an Angular service for fetching the content.

The reason is server-side rendering. When you render your page on the server side, you don't want to include the lazy-loaded content. Because that would insert the HTML into the initial page payload sent to the browser, rather than lazy-loading it after the initial page render on the client side.

You also don't want to simply drop the Javascript for doing the lazy loading into your Angular component's .ts file. Because that would have the same effect: It would run during the server-side rendering, and you don't want that. Even if you can make it work, it's not what you want.

Oscar helped me to understand how to use Renderer2 to insert the script for lazy-loading the content into the DOM, and deferring the execution of that script until the page renders in the browser. The script will not run during the Angular rendering process on the server side.

Getting it to work on the server side was a pain in the neck because of the window vs global issue. I learned a lot about how Angular Universal fakes a DOM using Domino, and that was interesting. But as I was working on it, I slowly realized that it was the wrong problem to solve. And that Oscar had already shown me the way.

Here's a Git commit on the sample project showing how I removed the FooterService and used Renderer2 to insert the lazy-loading code into the rendered output: https://github.com/endymion/angular-jsonp-problem/commit/5f91efa36f33a699da49044db63d22e3c7892a4d

I learned a lot through all of this, and I hope these notes help someone else someday. Thanks again, Oscar, and please have a great weekend.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.