reactjs / React.NET

.NET library for JSX compilation and server-side rendering of React components
https://reactjs.net/
MIT License
2.3k stars 937 forks source link

Expose skipLazyInit on HtmlHelperextension #1255

Open LorenDorez opened 3 years ago

LorenDorez commented 3 years ago

can you please expose the new parameter skipLazyInit in the HTML Helper extension?

dustinsoftware commented 3 years ago

Sure, send a PR

On Wed, May 12, 2021 at 11:28, Loren Dorez @.***> wrote:

can you please expose the new parameter skipLazyInit in the HTML Helper extension?

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/reactjs/React.NET/issues/1255, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAHGCFRYVCOXZY2KSRVQUP3TNKNBTANCNFSM44Y2XJQA .

LorenDorez commented 3 years ago

Also ill need some way to filter the ReactInitJavaScript() calls to filter out the loadable-components ones. Ill see if i can work up a PR for all of that. As this should allow load-able components to load fully.

I have everything working just need to wrap the initialize JS scripts in the loadableRead() function

LorenDorez commented 3 years ago

im fairly new to the PR/Git stuff but ill do my best :)

LorenDorez commented 3 years ago

@dustinsoftware Do you have any issues with me also adding some separate logic to leverage the loadable-components library for code splitting/dynamic imports?

Basically would add some flags an properties to keep track of components that are code split/Lazy Init and then render their JS separately with like Html.ReactLazyInitJavaScript()

This wouldn't require the project to take on a dependency of Loadable Components but would just have some helper sin place for people that want to use it. I can separate that into a separate pull request as the one above should be a quick 2 sec change really

dustinsoftware commented 3 years ago

Go for it :)

On Fri, May 14, 2021 at 09:57, Loren Dorez @.***> wrote:

@dustinsoftware https://github.com/dustinsoftware Do you have any issues with me also adding some separate logic to leverage the loadable-components library for code splitting/dynamic imports?

Basically would add some flags an properties to keep track of components that are code split/Lazy Init and then render their JS separately with like Html.ReactLazyInitJavaScript()

This wouldn't require the project to take on a dependency of Loadable Components but would just have some helper sin place for people that want to use it. I can separate that into a separate pull request as the one above should be a quick 2 sec change really

— You are receiving this because you were mentioned.

Reply to this email directly, view it on GitHub https://github.com/reactjs/React.NET/issues/1255#issuecomment-841260682, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAHGCFS3FHI3GJ6GGHXJ5B3TNUT4DANCNFSM44Y2XJQA .

PatrickNausha commented 3 years ago

@LorenDorez, I wrote a prototype for using loadable-components in my project partly based on https://github.com/reactjs/React.NET/issues/1250 (thanks!). It seems like we've taken a different strategy with how we handle ReactInitJavaScript. I'd like to compare notes and see if we can reach a common solution.

Also ill need some way to filter the ReactInitJavaScript() calls to filter out the loadable-components ones.

How does this help make loadable-components more workable for you? Are you emitting ReactDOM.hydrate calls into the page markup manually?

As a proof of concept jus to get loadable-components working, I've wrapped the JS returned by ReactInitJavaScript in a callback so I can force ReactDOM calls to happen inside a loadableReady callback. This looks like

var initJavascript = html.ReactInitJavaScript(clientOnly: !circuitBreakerEnabled).ToHtmlString();
var initJavascriptWithWrapperFunction = Regex.Replace(
    initJavascript,
    "^(<script.*?>)(.*)(</script>)",
    "$1window.loadableHydrateCallback = function () { $2 };$3",
    RegexOptions.Singleline);

Then in my webpack entrypoint JS module I have

import { loadableReady } from '@loadable/component';

loadableReady(() => {
    window.loadableHydrateCallback();
});

Again, this is just a proof of concept. What I might actually want is stable API support for wrapping the render/hydrate calls in an arbitrary function. We more or less have such a thing baked in for a DOMContentLoaded event handler. https://github.com/reactjs/React.NET/blob/6d4a15acbc4004c5c9be833dd07328e63c791578/src/React.Core/ReactComponent.cs#L247-L265

At this point I'm going to close the code-splitting issues; it's likely this library will never support that use case for SSR. It's possible today for your components to use dynamic import after the main component has loaded (the webpack example does this).

@dustinsoftware, regarding the above comment pasted from other closed issues: Is the problem here that there's a hard technical limitation preventing React.NET from supporting loadable-components? Or has this just not been prioritized/pushed forward by anyone? I'm interested in opening PRs to make React.NET support loadable-components in a way that's compatible with the rest of the library's design.

dustinsoftware commented 3 years ago

If you are willing to contribute time towards making this work, I’d happily accept PRs and work with you to get it shipped! :)

LorenDorez commented 3 years ago

Our setup required a bit if specialized changes that I haven't had time to completely work out a pull request.

I ended up making a wrapper around the ReactEnvironment and htmlhelper classes to expose and handle the changes we needed for now. So my react environment wrapper basically stores all component s that have a render function for loadable components, then has a init javascript method that merges the normal react environment js and the loadable stuff.

But I basically just run everything through the loadableready now it was just simpler since now we have a single entry point.

See issue #1250

PatrickNausha commented 3 years ago

now we have a single entry point.

The problem I'm currently encountering is that we have multiple calls to React.Web.Mvc.React per page. This means I have duplicate script tags for any code that the multiple react roots have in common. This is because I have one ChunkExtractor and one extractor.getScriptTags JS call per React.Web.Mvc.React C# call. I don't know how to persist a ChunkExtractor JS object across multiple C# calls to React or whether this is even possible.

I set different options.namespace values on the multiple ChunkExtractors to get multiple React calls working at all.

Possible solutions I can think of are

  1. PR loadable-components to make ChunkExtractor serializable to/from a string. I could store that in C# between React calls on the same page and re-inject it into each JS engine for re-use. I might already be able to store the extractor's chunks and rebuild it with addChunk.
  2. Eliminate the multiple calls to React.Web.Mvc.React in my project. This would require rather large refactors in my project.
  3. Write some hacky code to merge the script tags emitted by all extractor.getScriptTags together. This would include the __LOADABLE_REQUIRED_CHUNKS__ and __LOADABLE_REQUIRED_CHUNKS___ext scripts. This seems unappealing because future versions of loadable-components could easily break this.

I'm going to try a proof of concept for option 1 by using chunks and addChunk.

Update: chunks + addChunks for serialization (Option 1) seems to be working for me. I'm inquiring about the hackyness and possible alternatives in https://github.com/gregberge/loadable-components/issues/820

a-karandashov commented 2 years ago

Hi @PatrickNausha Did you solve the issues with loadable-components integration? :)

PatrickNausha commented 2 years ago

Yes. We've been using loadable-components and server-side rendering with React.NET in production for a few months now. I essentially went with option 3 in my previous post above.

At the of taking this issue way off the topic of "Expose skipLazyInit on HtmlHelperextension," here's an overview of our solution below.

Step 1: Create a custom RenderFunctionsBase

We have a LoadableFunction custom render function that creates a Loadable Components ChunkExtractor for each render and returns the chunks in the ChunkExtractor back to C# after each render. This will later let us emit the proper link, script, and style tags into the page for our dynamic chunks.

React render calls look like

var componentHtml = html.React(
    ...,
    renderFunctions: new ChainedRenderFunctions(loadableFunction, ...)
).ToHtmlString();

The custom LoadableFunction (with specifics for my app stripped out) looks like

public class LoadableFunction : RenderFunctionsBase
{
    ...

    // Scripts for all loadable components rendered with this function. See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetscripttags
    public string Scripts { get; private set; } = "";

    // Get preload link tags for all loadable components rendered with this function. See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetlinktags
    public string Links { get; private set; } = "";

    // Get style link tags for all loadable components rendered with this function. See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetstyletags
    public string Styles { get; private set; } = "";

    public override void PreRender(Func<string, string> executeJs)
    {
        ...
        executeJs($"var extractor = new ChunkExtractor({{ ... }});");

        // Add chunks seen in previous renders into the new ChunkExtractor
        executeJs($"for (const chunk of {m_chunksJson}) {{ extractor.addChunk(chunk); }}");
    }

    public override string WrapComponent(string componentToRender)
    {
        return $"extractor.collectChunks({componentToRender})";
    }

    public override void PostRender(Func<string, string> executeJs)
    {
        // Store chunks so we can build a ChunkExtractor on the next render that essentially picks up where we left off.
        m_chunksJson = executeJs($"JSON.stringify(extractor.chunks)");

        // See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetscripttags
        Scripts = $"{executeJs("extractor.getScriptTags({ ... })")}";

        // See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetstyletags
        Styles = $"{executeJs("extractor.getStyleTags({ ... })")}";

        // See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetlinktags
        Links = $"{executeJs("extractor.getLinkTags({ ... })")}";
    }

    ...

    private string m_chunksJson = "[]";
}

In the head of the page in our Razor view, we invoke a method that essentially spits out loadableFunction.Links + loadableFunction.Styles.

Late in the body of our page we render @Html.RenderReactInitJavascript() followed by a method call that essentially returns just loadableFunction.Scripts.

We use our app's dependency injection system to make our LoadableFunction instance available to Razor but scope its lifetime to exactly one request.

Step 2: Create a custom ReactEnvironment

We need a way to override React.NET's default behavior of generating script elements that invoke ReactDOM.hydrate directly. This is because we need to wrap hydrate calls in a loadableReady callback.

Early in the page we define window.loadableReadyCallbacks.

<script type="text/javascript">
    window.loadableReadyCallbacks = [];
</script>

Our LoadableComponentsReactEnvironment below writes to window.loadableReadyCallbacks on renders.

using System.IO;
using React;

namespace Faithlife.Web.Infrastructure
{
    internal sealed class LoadableComponentsReactEnvironment : ReactEnvironment
    {
        public LoadableComponentsReactEnvironment(
            IJavaScriptEngineFactory engineFactory,
            IReactSiteConfiguration config,
            ICache cache,
            IFileSystem fileSystem,
            IFileCacheHash fileCacheHash,
            IReactIdGenerator reactIdGenerator
        )
            : base(engineFactory, config, cache, fileSystem, fileCacheHash, reactIdGenerator)
        {
            m_reactIdGenerator = reactIdGenerator;
        }

        // This is intended to be identical to React.NET's ReactEnvironment.CreateComponent except that it creates
        // a LoadableComponentsComponent instead of ReactComponent.
        public override IReactComponent CreateComponent<T>(
            string componentName,
            T props,
            string containerId = null,
            bool clientOnly = false,
            bool serverOnly = false)
        {
            if (!clientOnly)
                EnsureUserScriptsLoaded();
            ReactComponent reactComponent = new LoadableComponentsComponent(this, _config, m_reactIdGenerator, componentName, containerId)
            {
                ClientOnly = clientOnly,
                Props = props,
                ServerOnly = serverOnly,
            };
            _components.Add(reactComponent);
            return reactComponent;
        }

        private sealed class LoadableComponentsComponent : ReactComponent
        {
            public LoadableComponentsComponent(IReactEnvironment environment, IReactSiteConfiguration configuration, IReactIdGenerator reactIdGenerator, string componentName, string containerId)
                : base(environment, configuration, reactIdGenerator, componentName, containerId)
            { }

            public override void RenderJavaScript(TextWriter writer)
            {
                var callbackStart = ClientOnly ?
                    "window.addEventListener('DOMContentLoaded', function () {" :
                    "window.loadableReadyCallbacks.push(function () {";
                writer.Write(callbackStart);
                base.RenderJavaScript(writer);
                writer.Write("});");
            }
        }

        private readonly IReactIdGenerator m_reactIdGenerator;
    }
}

Now that we have our list of callbacks (list of ReactDOM.hydrate calls), invoke them with loadableReady in the browser.

import { loadableReady } from '@loadable/component';

loadableReady(() => {
    for (const invokeCallback of window.loadableReadyCallbacks) {
        invokeCallback();
    }
});

We need something like this at app startup to actually use the custom IReactEnvironment.

// Override React.NET types
var container = AssemblyRegistration.Container;
container.Register<IReactEnvironment, LoadableComponentsReactEnvironment>()
    .AsPerRequestSingleton();

Step 3: Disable code-splitting in server JavaScript bundle

We use webpack to build a browser JS bundle and a server JS bundle. React.NET's JavaScript engines don't have a way to dynamically load JS, so just load everything on the server. This makes dynamic imports for loadable components a no-op since all the chunks are already there.

new webpack.optimize.LimitChunkCountPlugin({
    maxChunks: 1
}),

I'm probably forgetting some major things, but this is the best I can easily distill my project's approach. If I had more time, I'd create a minimal, open source demo app. I hope this information helps someone. Good luck!

dustinsoftware commented 2 years ago

Excellent write up, thanks Patrick

On Thu, Jan 20, 2022 at 19:25, PatrickNausha @.***> wrote:

Yes. We've been using loadable-components and server-side rendering with React.NET in production for a few months now. I essentially went with option 3 in my previous post above.

At the of taking this issue way off the topic of "Expose skipLazyInit on HtmlHelperextension," here's an overview of our solution below. Step 1: Create a custom RenderFunctionsBase

We have a LoadableFunction custom render function that creates a Loadable Components ChunkExtractor for each render and returns the chunks in the ChunkExtractor back to C# after each render. This will later let us emit the proper link, script, and style tags into the page for our dynamic chunks.

React render calls look like

var componentHtml = html.React( ..., renderFunctions: new ChainedRenderFunctions(loadableFunction, ...) ).ToHtmlString();

The custom LoadableFunction (with specifics for my app stripped out) looks like

public class LoadableFunction : RenderFunctionsBase { ...

// Scripts for all loadable components rendered with this function. See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetscripttags
public string Scripts { get; private set; } = "";

// Get preload link tags for all loadable components rendered with this function. See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetlinktags
public string Links { get; private set; } = "";

// Get style link tags for all loadable components rendered with this function. See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetstyletags
public string Styles { get; private set; } = "";

public override void PreRender(Func<string, string> executeJs)
{
    ...
    executeJs($"var extractor = new ChunkExtractor({{ ... }});");

    // Add chunks seen in previous renders into the new ChunkExtractor
    executeJs($"for (const chunk of {m_chunksJson}) {{ extractor.addChunk(chunk); }}");
}

public override string WrapComponent(string componentToRender)
{
    return $"extractor.collectChunks({componentToRender})";
}

public override void PostRender(Func<string, string> executeJs)
{
    // Store chunks so we can build a ChunkExtractor on the next render that essentially picks up where we left off.
    m_chunksJson = executeJs($"JSON.stringify(extractor.chunks)");

    // See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetscripttags
    Scripts = $"{executeJs("extractor.getScriptTags({ ... })")}";

    // See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetstyletags
    Styles = $"{executeJs("extractor.getStyleTags({ ... })")}";

    // See https://loadable-components.com/docs/api-loadable-server/#chunkextractorgetlinktags
    Links = $"{executeJs("extractor.getLinkTags({ ... })")}";
}

...

private string m_chunksJson = "[]";

}

In the head of the page in our Razor view, we invoke a method that essentially spits out loadableFunction.Links + loadableFunction.Styles.

Late in the body of our page we render @Html.RenderReactInitJavascript() followed by a method call that essentially returns just loadableFunction.Scripts.

We use our app's dependency injection system to make our LoadableFunction instance available to Razor but scope its lifetime to exactly one request. Step 2: Create a custom ReactEnvironment

We need a way to override React.NET's default behavior of generating script elements that invoke ReactDOM.hydrate directly. This is because we need to wrap hydrate calls in a loadableReady https://loadable-components.com/docs/api-loadable-component/#loadableready callback.

Early in the page we define window.loadableReadyCallbacks.

Our LoadableComponentsReactEnvironment below writes to window.loadableReadyCallbacks on renders.

using System.IO;using React; namespace Faithlife.Web.Infrastructure { internal sealed class LoadableComponentsReactEnvironment : ReactEnvironment { public LoadableComponentsReactEnvironment( IJavaScriptEngineFactory engineFactory, IReactSiteConfiguration config, ICache cache, IFileSystem fileSystem, IFileCacheHash fileCacheHash, IReactIdGenerator reactIdGenerator ) : base(engineFactory, config, cache, fileSystem, fileCacheHash, reactIdGenerator) { m_reactIdGenerator = reactIdGenerator; }

  // This is intended to be identical to React.NET's ReactEnvironment.CreateComponent except that it creates
  // a LoadableComponentsComponent instead of ReactComponent.
  public override IReactComponent CreateComponent<T>(
      string componentName,
      T props,
      string containerId = null,
      bool clientOnly = false,
      bool serverOnly = false)
  {
      if (!clientOnly)
          EnsureUserScriptsLoaded();
      ReactComponent reactComponent = new LoadableComponentsComponent(this, _config, m_reactIdGenerator, componentName, containerId)
      {
          ClientOnly = clientOnly,
          Props = props,
          ServerOnly = serverOnly,
      };
      _components.Add(reactComponent);
      return reactComponent;
  }

  private sealed class LoadableComponentsComponent : ReactComponent
  {
      public LoadableComponentsComponent(IReactEnvironment environment, IReactSiteConfiguration configuration, IReactIdGenerator reactIdGenerator, string componentName, string containerId)
          : base(environment, configuration, reactIdGenerator, componentName, containerId)
      { }

      public override void RenderJavaScript(TextWriter writer)
      {
          var callbackStart = ClientOnly ?
              "window.addEventListener('DOMContentLoaded', function () {" :
              "window.loadableReadyCallbacks.push(function () {";
          writer.Write(callbackStart);
          base.RenderJavaScript(writer);
          writer.Write("});");
      }
  }

  private readonly IReactIdGenerator m_reactIdGenerator;

} }

Now that we have our list of callbacks (list of ReactDOM.hydrate calls), invoke them with loadableReady in the browser.

import { loadableReady } from @.***/component'; loadableReady(() => { for (const invokeCallback of window.loadableReadyCallbacks) { invokeCallback(); }});

We need something like this at app startup to actually use the custom IReactEnvironment.

// Override React.NET typesvar container = AssemblyRegistration.Container;container.Register<IReactEnvironment, LoadableComponentsReactEnvironment>() .AsPerRequestSingleton();

Step 3: Disable code-splitting in server JavaScript bundle

We use webpack to build a browser JS bundle and a server JS bundle. React.NET's JavaScript engines don't have a way to dynamically load JS, so just load everything on the server. This makes dynamic imports for loadable components a no-op since all the chunks are already there.

new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1}),

I'm probably forgetting some major things, but this is the best I can easily distill my project's approach. If I had more time, I'd create a minimal, open source demo app. I hope this information helps someone. Good luck!

— Reply to this email directly, view it on GitHub https://github.com/reactjs/React.NET/issues/1255#issuecomment-1018045191, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAHGCFT57JNKMCWHMIY5AZDUXCRW3ANCNFSM44Y2XJQA . Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.

You are receiving this because you were mentioned.Message ID: @.***>