Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/JsxCore/Assets/preact/server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Server entry for Preact. Loaded by the .NET host, which calls renderView/readHead and reads the
// JSON they return; nothing above this layer knows how a view is rendered.
// markup and head they return; nothing above this layer knows how a view is rendered.

import { createElement } from "preact";
import { render } from "preact-render-to-string";
Expand Down
2 changes: 1 addition & 1 deletion src/JsxCore/Assets/react/server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Server entry for React. Loaded by the .NET host, which calls renderView/readHead and reads the
// JSON they return; the contract is identical to every other framework's entry.
// markup and head they return; the contract is identical to every other framework's entry.

import React from "react";
import ReactDomServer from "react-dom/server.browser";
Expand Down
19 changes: 12 additions & 7 deletions src/JsxCore/Assets/shared/view-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,13 @@ function synchronous(Component) {
}

/**
* Builds a framework's server entry: renderView and readHead, which the .NET host calls and whose
* JSON it reads. The contract is identical for every framework, which is the point.
* Builds a framework's server entry: renderView and readHead, which the .NET host calls and reads
* the result of. The contract is identical for every framework, which is the point.
*
* Both return { html, head }: the markup as the string it already is, and the head descriptor as
* JSON, or null when the view contributed no head at all. A whole page of markup is by far the
* larger of the two and gains nothing from being escaped and unescaped on the way out; the head
* descriptor is a handful of tags with a shape the host already knows how to read.
*/
export function createServerEntry(createElement, renderToString) {
return {
Expand All @@ -158,17 +163,17 @@ export function createServerEntry(createElement, renderToString) {
synchronous(Component), { model: props.model, context: props.context });

const rendered = renderCollectingHead(() => renderToString(element));
const head = mergeHead(resolveHead(viewModule, props), rendered.contributed);

return JSON.stringify({
html: rendered.html,
head: mergeHead(resolveHead(viewModule, props), rendered.contributed)
});
return { html: rendered.html, head: head ? JSON.stringify(head) : null };
},

// Only the head export, because the component is not run in this pass. A <Head> inside a
// client-rendered view is applied by the browser after it mounts.
readHead(viewModule, props) {
return JSON.stringify({ html: "", head: resolveHead(viewModule, props) });
const head = resolveHead(viewModule, props);

return { html: "", head: head ? JSON.stringify(head) : null };
}
};
}
2 changes: 1 addition & 1 deletion src/JsxCore/JsxCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Jint" Version="4.15.1" />
<PackageReference Include="Jint" Version="4.15.3" />
</ItemGroup>

<!--
Expand Down
23 changes: 23 additions & 0 deletions src/JsxCore/JsxCoreOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,27 @@ public sealed class ServerRenderOptions
/// </summary>
public bool ExposeCamelCaseMembers { get; set; } = true;

/// <summary>
/// CLR types whose instances never change while a view can see them.
/// </summary>
/// <remarks>
/// <para>
/// Declaring a type is a promise about every instance of it that reaches a render: neither its
/// members nor the values behind them are mutated for as long as the render can reach it. In
/// exchange the engine resolves each member of such an object once instead of on every access,
/// which is worth having for a registered global whose methods hand back data records that a
/// view then walks repeatedly — <c>catalog.getProduct().pricing.currency</c> inside a loop
/// crosses into .NET once per object rather than once per read.
/// </para>
/// <para>
/// A broken promise is answered with stale reads rather than an error, so declare only types
/// the application controls. Assignability is the rule: declaring an interface or a base type
/// covers everything that implements or derives from it.
/// </para>
/// <para>
/// Internal while the shape of the public option is still open. Applications cannot reach this
/// yet, and the tests are what exercise it.
/// </para>
/// </remarks>
internal List<Type> ImmutableCrossingTypes { get; } = [];
}
47 changes: 40 additions & 7 deletions src/JsxCore/Rendering/JsxModuleLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@ public sealed class JsxModuleLoader : IModuleLoader
private readonly JsxRuntimeLayout _runtime;
private readonly string? _runtimeAssetDirectory;
private readonly NodeModuleResolver? _npm;
private readonly ServerModuleCache? _modules;

public JsxModuleLoader(CompilationLayout layout, JsxRuntimeLayout runtime, NodeModuleResolver? npm = null)
: this(layout, runtime, npm, modules: null)
{
}

internal JsxModuleLoader(
CompilationLayout layout,
JsxRuntimeLayout runtime,
NodeModuleResolver? npm,
ServerModuleCache? modules)
{
ArgumentNullException.ThrowIfNull(layout);
_npm = npm;
_modules = modules;
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_outputDirectory = Path.GetFullPath(layout.OutputDirectory);
_runtimeDirectory = Path.GetFullPath(layout.RuntimeDirectory);
Expand Down Expand Up @@ -142,8 +153,18 @@ public Module LoadModule(Engine engine, ResolvedSpecifier resolved)
return LoadPackageModule(engine, resolved);
}

var source = ReadSource(resolved.Key);
return ModuleFactory.BuildSourceTextModule(engine, resolved, source, new ModuleParsingOptions());
if (_modules is null)
{
var source = ReadSource(resolved.Key);
return ModuleFactory.BuildSourceTextModule(engine, resolved, source, new ModuleParsingOptions());
}

// The name the engine gives back as the referencing location when this module's own
// relative imports are resolved, so it has to be the one the engine would have derived
// from the resolved specifier itself.
var location = resolved.Uri?.LocalPath ?? resolved.Key;
var prepared = _modules.GetOrParse(location, () => ReadSource(resolved.Key));
return ModuleFactory.BuildSourceTextModule(engine, in prepared);
}

private string ReadSource(string path)
Expand Down Expand Up @@ -188,8 +209,8 @@ private Module LoadPackageModule(Engine engine, ResolvedSpecifier resolved)
throw new JsxCoreException($"JsxCore could not read the package module '{path}'.");
}

var source = File.ReadAllText(path);
var kind = _npm!.KindOf(path);
var npm = _npm!;
var kind = npm.KindOf(path);

// JSON keeps the engine's own module type rather than being re-expressed as
// "export default <json>". The two are not equivalent, and not in a harmless direction:
Expand All @@ -199,11 +220,23 @@ private Module LoadPackageModule(Engine engine, ResolvedSpecifier resolved)
// which is why the two hosts differ here, and only here.
if (kind == NodeModuleKind.Json)
{
return ModuleFactory.BuildJsonModule(engine, resolved, source);
return ModuleFactory.BuildJsonModule(engine, resolved, File.ReadAllText(path));
}

var shaped = ModuleTransform.Apply(path, kind, source, new EngineSpecifierRewriter(_npm));
return ModuleFactory.BuildSourceTextModule(engine, resolved, shaped.Source, new ModuleParsingOptions());
if (_modules is null)
{
var shaped = ModuleTransform.Apply(path, kind, File.ReadAllText(path), new EngineSpecifierRewriter(npm));
return ModuleFactory.BuildSourceTextModule(engine, resolved, shaped.Source, new ModuleParsingOptions());
}

// What is cached is the module as the engine sees it, after the CommonJS wrapping, so a
// second engine pays for neither the read nor the transform.
var location = resolved.Uri?.LocalPath ?? resolved.Key;
var prepared = _modules.GetOrParse(
location,
() => ModuleTransform.Apply(path, kind, File.ReadAllText(path), new EngineSpecifierRewriter(npm)).Source);

return ModuleFactory.BuildSourceTextModule(engine, in prepared);
}

}
Loading