@ecopages/ecopages-jsx 0.2.0-beta.9 → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,79 +1,113 @@
1
- import { createMarkupNodeLike } from "@ecopages/jsx";
2
- import { createServerHydrationBindingState, withServerHydrationBindingState } from "@ecopages/jsx/server";
3
- class EcopagesJsxRadiantSsrPolicy {
4
- static runtimeModules;
5
- static runtimeModulesPromise;
6
- enabled;
7
- constructor(enabled) {
8
- this.enabled = enabled;
9
- }
10
- /**
11
- * Ensures the Radiant SSR runtime is installed before a render needs it.
12
- */
13
- async prepareRuntime() {
14
- if (!this.enabled) {
15
- return;
1
+ import { createMarkupNodeLike } from '@ecopages/jsx';
2
+ import { createServerHydrationBindingState, withServerHydrationBindingState } from '@ecopages/jsx/server';
3
+ /**
4
+ * Owns optional Radiant SSR runtime installation for the JSX integration.
5
+ *
6
+ * @remarks
7
+ * Radiant's server bridge and light-DOM shim are loaded lazily because most JSX
8
+ * renders do not need them. Resolved runtime modules are cached on the policy
9
+ * instance so repeated renders on the same renderer reuse one load without
10
+ * process-wide mutable class state. Light-DOM constructor installation remains
11
+ * process-global and idempotent via {@link ensureRadiantLightDomGlobals}; the
12
+ * Radiant installer also repairs incomplete DOM globals left by another runtime.
13
+ */
14
+ export class EcopagesJsxRadiantSsrPolicy {
15
+ runtimeModules;
16
+ runtimeModulesPromise;
17
+ enabled;
18
+ constructor(enabled) {
19
+ this.enabled = enabled;
16
20
  }
17
- await this.ensureRuntimeInstalled();
18
- }
19
- /**
20
- * Runs one render inside Radiant's server runtime when the policy is enabled.
21
- */
22
- async withRuntime(render) {
23
- if (!this.enabled) {
24
- return render();
21
+ /**
22
+ * Ensures the Radiant SSR runtime is installed before a render needs it.
23
+ */
24
+ async prepareRuntime() {
25
+ if (!this.enabled) {
26
+ return;
27
+ }
28
+ await this.ensureRuntimeInstalled();
25
29
  }
26
- const runtimeModules = await EcopagesJsxRadiantSsrPolicy.runtimeModulesPromise;
27
- if (!runtimeModules) {
28
- return render();
30
+ /**
31
+ * Runs one render inside Radiant's server runtime when the policy is enabled.
32
+ */
33
+ async withRuntime(render) {
34
+ if (!this.enabled) {
35
+ return render();
36
+ }
37
+ const runtimeModules = await this.runtimeModulesPromise;
38
+ if (!runtimeModules) {
39
+ return render();
40
+ }
41
+ return runtimeModules.withServerRadiantElementSsrRuntime(render);
29
42
  }
30
- return runtimeModules.withServerRadiantElementSsrRuntime(render);
31
- }
32
- /**
33
- * Converts one Radiant custom-element instance into trusted SSR markup.
34
- *
35
- * @remarks
36
- * The returned node-like wrapper lets the JSX server renderer preserve the
37
- * generated host HTML without escaping it back into plain text.
38
- */
39
- renderIntrinsicElementMarkup(instance) {
40
- const renderBridge = EcopagesJsxRadiantSsrPolicy.runtimeModules?.resolveRadiantElementRenderBridge(instance);
41
- if (!renderBridge) {
42
- return void 0;
43
+ /**
44
+ * Converts one Radiant custom-element instance into trusted SSR markup.
45
+ *
46
+ * @remarks
47
+ * The returned node-like wrapper lets the JSX server renderer preserve the
48
+ * generated host HTML without escaping it back into plain text.
49
+ */
50
+ renderIntrinsicElementMarkup(instance) {
51
+ const renderBridge = this.runtimeModules?.resolveRadiantElementRenderBridge(instance);
52
+ if (!renderBridge) {
53
+ return undefined;
54
+ }
55
+ return createMarkupNodeLike(withServerHydrationBindingState(createServerHydrationBindingState(), () => renderBridge.renderHostToString({
56
+ mode: 'hydrate',
57
+ })));
43
58
  }
44
- return createMarkupNodeLike(
45
- withServerHydrationBindingState(
46
- createServerHydrationBindingState(),
47
- () => renderBridge.renderHostToString({
48
- mode: "hydrate"
49
- })
50
- )
51
- );
52
- }
53
- async ensureRuntimeInstalled() {
54
- if (!EcopagesJsxRadiantSsrPolicy.runtimeModulesPromise) {
55
- const radiantLightDomShimEntry = import.meta.resolve("@ecopages/radiant/server/light-dom-shim");
56
- const radiantElementSsrRuntimeModuleUrl = new URL(
57
- "./radiant-element-ssr-bridge.js",
58
- radiantLightDomShimEntry
59
- ).href;
60
- EcopagesJsxRadiantSsrPolicy.runtimeModulesPromise = Promise.all([
61
- import(radiantElementSsrRuntimeModuleUrl),
62
- import(radiantLightDomShimEntry)
63
- ]).then(([radiantElementSsrRuntimeModule, lightDomShimModule2]) => {
64
- const modules = {
65
- installLightDomShim: lightDomShimModule2.installLightDomShim,
66
- resolveRadiantElementRenderBridge: radiantElementSsrRuntimeModule.resolveRadiantElementRenderBridge,
67
- withServerRadiantElementSsrRuntime: radiantElementSsrRuntimeModule.withServerRadiantElementSsrRuntime
68
- };
69
- EcopagesJsxRadiantSsrPolicy.runtimeModules = modules;
70
- return modules;
71
- });
59
+ async ensureRuntimeInstalled() {
60
+ if (!this.runtimeModulesPromise) {
61
+ const radiantLightDomShimEntry = import.meta.resolve('@ecopages/radiant/server/light-dom-shim');
62
+ const radiantElementSsrRuntimeModuleUrl = new URL('./radiant-element-ssr-bridge.js', radiantLightDomShimEntry).href;
63
+ this.runtimeModulesPromise = (async () => {
64
+ const lightDomShimModule = await import(radiantLightDomShimEntry);
65
+ ensureRadiantLightDomGlobals(lightDomShimModule.ensureLightDomShim);
66
+ const radiantElementSsrRuntimeModule = (await import(radiantElementSsrRuntimeModuleUrl));
67
+ const modules = {
68
+ ensureLightDomShim: lightDomShimModule.ensureLightDomShim,
69
+ resolveRadiantElementRenderBridge: radiantElementSsrRuntimeModule.resolveRadiantElementRenderBridge,
70
+ withServerRadiantElementSsrRuntime: radiantElementSsrRuntimeModule.withServerRadiantElementSsrRuntime,
71
+ };
72
+ this.runtimeModules = modules;
73
+ return modules;
74
+ })();
75
+ }
76
+ await this.runtimeModulesPromise;
77
+ const lightDomShimModule = this.runtimeModules;
78
+ if (lightDomShimModule) {
79
+ ensureRadiantLightDomGlobals(lightDomShimModule.ensureLightDomShim);
80
+ }
72
81
  }
73
- const lightDomShimModule = await EcopagesJsxRadiantSsrPolicy.runtimeModulesPromise;
74
- lightDomShimModule.installLightDomShim();
75
- }
76
82
  }
77
- export {
78
- EcopagesJsxRadiantSsrPolicy
79
- };
83
+ /**
84
+ * Installs Radiant light-DOM constructors on `globalThis` once.
85
+ *
86
+ * @remarks
87
+ * Custom-element constructors must be process-global; undoing them between tests
88
+ * is unsafe. Callers that clear globals for isolation must rely on a fresh policy
89
+ * instance so {@link EcopagesJsxRadiantSsrPolicy.prepareRuntime} re-runs the
90
+ * completeness check.
91
+ */
92
+ function ensureRadiantLightDomGlobals(ensureLightDomShim) {
93
+ const window = ensureLightDomShim();
94
+ if (!window) {
95
+ return;
96
+ }
97
+ Object.assign(globalThis, {
98
+ CSS: window.CSS,
99
+ CustomEvent: window.CustomEvent,
100
+ Document: window.Document,
101
+ Element: window.Element,
102
+ Event: window.Event,
103
+ EventTarget: window.EventTarget,
104
+ HTMLScriptElement: window.HTMLScriptElement,
105
+ HTMLElement: window.HTMLElement,
106
+ Node: window.Node,
107
+ document: window.document,
108
+ customElements: window.customElements,
109
+ requestAnimationFrame: window.requestAnimationFrame,
110
+ cancelAnimationFrame: window.cancelAnimationFrame,
111
+ window,
112
+ });
113
+ }
@@ -1,3 +1,4 @@
1
+ import type { EcoComponent } from '@ecopages/core';
1
2
  import type { ProcessedAsset } from '@ecopages/core/services/asset-processing-service';
2
3
  export declare const ECOPAGES_JSX_SSR_RENDER_STATE_KEY: unique symbol;
3
4
  /**
@@ -21,18 +22,13 @@ export declare class EcopagesJsxRenderSession {
21
22
  * mirrored back into the JSX SSR scope so nested `renderToString()` calls see
22
23
  * the same asset frame stack. When no session exists yet, a new state is
23
24
  * created and published through both the internal async-local store and the
24
- * JSX SSR scope bridge.
25
+ * JSX SSR scope bridge. HMR ownership accumulated during the outer scope is
26
+ * published once when that scope completes.
25
27
  */
26
- withActiveScope<T>(render: () => T): T;
27
- /**
28
- * Runs work with the current page-level hydrate binding namespace attached to JSX SSR.
29
- *
30
- * This lets page, layout, and document renders participate in one client-owned
31
- * hydration root even though Ecopages composes them through multiple sibling render calls.
32
- */
33
- withHydrationBindingScope<T>(render: () => T): T;
28
+ withActiveScope<T>(render: () => T | Promise<T>): Promise<T>;
34
29
  beginCollectedAssetFrame(): ProcessedAsset[];
35
30
  endCollectedAssetFrame(frame: ProcessedAsset[]): ProcessedAsset[];
31
+ mergeHmrOwnership(components: ReadonlyArray<EcoComponent | undefined>): void;
36
32
  recordCollectedAssets(collectedAssets: ProcessedAsset[]): ProcessedAsset[];
37
33
  /**
38
34
  * Resolves the current render-session state from the local async scope first.
@@ -1,93 +1,111 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import {
3
- createServerHydrationBindingState,
4
- getActiveSsrScopeValue,
5
- withActiveSsrScopeValue,
6
- withServerHydrationBindingState
7
- } from "@ecopages/jsx/server";
8
- const ECOPAGES_JSX_SSR_RENDER_STATE_KEY = /* @__PURE__ */ Symbol.for("@ecopages/ecopages-jsx.ssr-render-state");
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { getActiveSsrScopeValue, withActiveSsrScopeValue } from '@ecopages/jsx/server';
3
+ import { mergeEjsxHmrOwnership, publishEjsxHmrOwnership } from './ecopages-jsx-hmr-ownership.js';
4
+ export const ECOPAGES_JSX_SSR_RENDER_STATE_KEY = Symbol.for('@ecopages/ecopages-jsx.ssr-render-state');
9
5
  const renderStateStorage = new AsyncLocalStorage();
10
- class EcopagesJsxRenderSession {
11
- dedupeProcessedAssets;
12
- constructor(dedupeProcessedAssets) {
13
- this.dedupeProcessedAssets = dedupeProcessedAssets;
14
- }
15
- /**
16
- * Runs one render inside the active session scope.
17
- *
18
- * @remarks
19
- * When a render is already active, the current session state is reused and
20
- * mirrored back into the JSX SSR scope so nested `renderToString()` calls see
21
- * the same asset frame stack. When no session exists yet, a new state is
22
- * created and published through both the internal async-local store and the
23
- * JSX SSR scope bridge.
24
- */
25
- withActiveScope(render) {
26
- const activeState = renderStateStorage.getStore();
27
- if (activeState) {
28
- return withActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY, activeState, render);
6
+ function ensurePendingHmrFileOwners(state) {
7
+ if (!state.pendingHmrFileOwners) {
8
+ state.pendingHmrFileOwners = new Set();
29
9
  }
30
- const jsxScopeState = getActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY);
31
- if (jsxScopeState) {
32
- return renderStateStorage.run(jsxScopeState, () => render());
10
+ return state.pendingHmrFileOwners;
11
+ }
12
+ function commitPendingHmrOwnership(state) {
13
+ const pending = state.pendingHmrFileOwners;
14
+ if (!pending || pending.size === 0) {
15
+ return;
16
+ }
17
+ publishEjsxHmrOwnership(pending);
18
+ pending.clear();
19
+ }
20
+ async function runWithCommittedHmrOwnership(state, render) {
21
+ try {
22
+ return await render();
23
+ }
24
+ finally {
25
+ commitPendingHmrOwnership(state);
26
+ }
27
+ }
28
+ /**
29
+ * Tracks JSX SSR asset-collection state for one active render flow.
30
+ *
31
+ * @remarks
32
+ * The renderer still mirrors this state into `@ecopages/jsx/server` so nested
33
+ * JSX renders and existing SSR-scope probes can observe it, but it also keeps
34
+ * an internal async-local copy. That fallback avoids losing renderer state when
35
+ * host runtimes such as Vite end up loading separate module identities for the
36
+ * JSX server helpers.
37
+ */
38
+ export class EcopagesJsxRenderSession {
39
+ dedupeProcessedAssets;
40
+ constructor(dedupeProcessedAssets) {
41
+ this.dedupeProcessedAssets = dedupeProcessedAssets;
42
+ }
43
+ /**
44
+ * Runs one render inside the active session scope.
45
+ *
46
+ * @remarks
47
+ * When a render is already active, the current session state is reused and
48
+ * mirrored back into the JSX SSR scope so nested `renderToString()` calls see
49
+ * the same asset frame stack. When no session exists yet, a new state is
50
+ * created and published through both the internal async-local store and the
51
+ * JSX SSR scope bridge. HMR ownership accumulated during the outer scope is
52
+ * published once when that scope completes.
53
+ */
54
+ async withActiveScope(render) {
55
+ const activeState = renderStateStorage.getStore();
56
+ if (activeState) {
57
+ return await withActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY, activeState, render);
58
+ }
59
+ const jsxScopeState = getActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY);
60
+ if (jsxScopeState) {
61
+ ensurePendingHmrFileOwners(jsxScopeState);
62
+ return await runWithCommittedHmrOwnership(jsxScopeState, () => renderStateStorage.run(jsxScopeState, () => render()));
63
+ }
64
+ const state = {
65
+ collectedAssetFrames: [],
66
+ pendingHmrFileOwners: new Set(),
67
+ };
68
+ return await runWithCommittedHmrOwnership(state, () => renderStateStorage.run(state, () => withActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY, state, render)));
69
+ }
70
+ beginCollectedAssetFrame() {
71
+ const state = this.getState();
72
+ const frame = [];
73
+ state.collectedAssetFrames.push(frame);
74
+ return frame;
75
+ }
76
+ endCollectedAssetFrame(frame) {
77
+ const activeFrame = this.getState().collectedAssetFrames.pop();
78
+ if (!activeFrame || activeFrame !== frame) {
79
+ return this.dedupeProcessedAssets(frame);
80
+ }
81
+ return this.dedupeProcessedAssets(activeFrame);
33
82
  }
34
- const state = {
35
- collectedAssetFrames: [],
36
- hydrationBindingState: createServerHydrationBindingState()
37
- };
38
- return renderStateStorage.run(
39
- state,
40
- () => withActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY, state, render)
41
- );
42
- }
43
- /**
44
- * Runs work with the current page-level hydrate binding namespace attached to JSX SSR.
45
- *
46
- * This lets page, layout, and document renders participate in one client-owned
47
- * hydration root even though Ecopages composes them through multiple sibling render calls.
48
- */
49
- withHydrationBindingScope(render) {
50
- return withServerHydrationBindingState(this.getState().hydrationBindingState, render);
51
- }
52
- beginCollectedAssetFrame() {
53
- const state = this.getState();
54
- const frame = [];
55
- state.collectedAssetFrames.push(frame);
56
- return frame;
57
- }
58
- endCollectedAssetFrame(frame) {
59
- const activeFrame = this.getState().collectedAssetFrames.pop();
60
- if (!activeFrame || activeFrame !== frame) {
61
- return this.dedupeProcessedAssets(frame);
83
+ mergeHmrOwnership(components) {
84
+ mergeEjsxHmrOwnership(ensurePendingHmrFileOwners(this.getState()), components);
62
85
  }
63
- return this.dedupeProcessedAssets(activeFrame);
64
- }
65
- recordCollectedAssets(collectedAssets) {
66
- const dedupedAssets = this.dedupeProcessedAssets(collectedAssets);
67
- const state = this.getState();
68
- const activeFrame = state.collectedAssetFrames[state.collectedAssetFrames.length - 1];
69
- if (activeFrame) {
70
- activeFrame.push(...dedupedAssets);
86
+ recordCollectedAssets(collectedAssets) {
87
+ const dedupedAssets = this.dedupeProcessedAssets(collectedAssets);
88
+ const state = this.getState();
89
+ const activeFrame = state.collectedAssetFrames[state.collectedAssetFrames.length - 1];
90
+ if (activeFrame) {
91
+ activeFrame.push(...dedupedAssets);
92
+ }
93
+ return dedupedAssets;
71
94
  }
72
- return dedupedAssets;
73
- }
74
- /**
75
- * Resolves the current render-session state from the local async scope first.
76
- *
77
- * @remarks
78
- * The fallback read from `@ecopages/jsx/server` keeps compatibility with code
79
- * that still inspects the public SSR scope directly, while the async-local
80
- * store remains the authoritative source for renderer-owned bookkeeping.
81
- */
82
- getState() {
83
- const state = renderStateStorage.getStore() ?? getActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY);
84
- if (!state) {
85
- throw new Error("Ecopages JSX SSR render state is unavailable outside the active render scope.");
95
+ /**
96
+ * Resolves the current render-session state from the local async scope first.
97
+ *
98
+ * @remarks
99
+ * The fallback read from `@ecopages/jsx/server` keeps compatibility with code
100
+ * that still inspects the public SSR scope directly, while the async-local
101
+ * store remains the authoritative source for renderer-owned bookkeeping.
102
+ */
103
+ getState() {
104
+ const state = renderStateStorage.getStore() ??
105
+ getActiveSsrScopeValue(ECOPAGES_JSX_SSR_RENDER_STATE_KEY);
106
+ if (!state) {
107
+ throw new Error('Ecopages JSX SSR render state is unavailable outside the active render scope.');
108
+ }
109
+ return state;
86
110
  }
87
- return state;
88
- }
89
111
  }
90
- export {
91
- ECOPAGES_JSX_SSR_RENDER_STATE_KEY,
92
- EcopagesJsxRenderSession
93
- };
@@ -1,6 +1,6 @@
1
1
  import type { ComponentRenderInput, ComponentRenderResult, EcoComponent, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';
2
- import { IntegrationRenderer, type RenderToResponseContext, type RouteModuleLoadOptions } from '@ecopages/core/route-renderer/integration-renderer';
3
- import type { ForeignChildRuntime } from '@ecopages/core/route-renderer/component-render-context';
2
+ import { IntegrationRenderer, type RenderToResponseContext, type RouteModuleLoadOptions } from '@ecopages/core/route-renderer/orchestration/integration-renderer';
3
+ import type { ForeignChildRuntime } from '@ecopages/core/route-renderer/orchestration/foreign-child/component-render-context';
4
4
  import { type JsxRenderable } from '@ecopages/jsx';
5
5
  import { type EcopagesJsxMdxPageModule } from './ecopages-jsx-mdx.js';
6
6
  import type { EcopagesJsxRendererOptions } from './ecopages-jsx.types.js';
@@ -16,19 +16,26 @@ export declare class EcopagesJsxRenderer extends IntegrationRenderer<JsxRenderab
16
16
  private readonly mdxExtensions;
17
17
  private readonly renderSession;
18
18
  private readonly radiantSsrPolicy;
19
+ /**
20
+ * Serializes foreign-child props for string-first boundaries.
21
+ *
22
+ * @remarks
23
+ * DOM nodes become `outerHTML`. JSX trees, arrays, and template results are
24
+ * stringified here. Other values are left for core foreign-child interception,
25
+ * which rejects opaque plain objects before queueing. Cross-integration
26
+ * children must use `EcoEmbed`.
27
+ */
19
28
  private normalizeForeignChildProps;
29
+ /**
30
+ * Returns whether children are a JSX/template shape this renderer can
31
+ * serialize before foreign-child queueing.
32
+ */
33
+ private isJsxSerializableForeignChild;
20
34
  /**
21
35
  * Re-renders queued JSX children inside the owning renderer so nested custom
22
36
  * elements and queued foreign subtrees contribute assets to the same frame.
23
37
  */
24
38
  private renderQueuedForeignSubtreeChildren;
25
- /**
26
- * Resolves queued foreign subtrees after JSX has been stringified.
27
- *
28
- * JSX content needs one extra render pass because child foreign subtrees may emit
29
- * additional browser assets while also replacing placeholder tokens.
30
- */
31
- private resolveOwnedForeignSubtreeHtml;
32
39
  protected createForeignChildRuntime(options: {
33
40
  renderInput: ComponentRenderInput;
34
41
  rendererCache: Map<string, IntegrationRenderer<any>>;
@@ -44,4 +51,10 @@ export declare class EcopagesJsxRenderer extends IntegrationRenderer<JsxRenderab
44
51
  private withCustomElementRenderHook;
45
52
  private withPreparedRadiantRuntime;
46
53
  private createIntrinsicCustomElementRenderHook;
54
+ /**
55
+ * Records the source files that produced the current render so
56
+ * {@link EcopagesJsxHmrStrategy} can match later watcher events without
57
+ * re-walking the component tree.
58
+ */
59
+ private recordHmrOwnership;
47
60
  }