@ecopages/react 0.2.0-alpha.14 → 0.2.0-alpha.15
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.
- package/CHANGELOG.md +11 -0
- package/package.json +3 -3
- package/src/react-renderer.d.ts +75 -58
- package/src/react-renderer.js +181 -240
- package/src/react.plugin.d.ts +20 -91
- package/src/react.plugin.js +85 -35
- package/src/react.types.d.ts +88 -0
- package/src/react.types.js +0 -0
- package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
- package/src/services/react-mdx-config-dependency.service.js +122 -0
- package/src/services/react-page-module.service.d.ts +5 -2
- package/src/services/react-page-module.service.js +21 -21
- package/src/services/react-page-payload.service.d.ts +46 -0
- package/src/services/react-page-payload.service.js +67 -0
- package/src/utils/component-config-traversal.d.ts +36 -0
- package/src/utils/component-config-traversal.js +54 -0
- package/src/utils/declared-modules.d.ts +1 -1
- package/src/utils/declared-modules.js +3 -15
- package/src/utils/dynamic.test.browser.d.ts +1 -0
- package/src/utils/dynamic.test.browser.js +33 -0
- package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
- package/src/utils/hydration-scripts.test.browser.js +126 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { LocalsAccessError } from "@ecopages/core/errors/locals-access-error";
|
|
2
|
+
class ReactPagePayloadService {
|
|
3
|
+
/**
|
|
4
|
+
* Creates the canonical page-props payload used by router hydration.
|
|
5
|
+
*
|
|
6
|
+
* React pages embedded in a non-React HTML shell still need to expose the same
|
|
7
|
+
* page-data contract as fully React-owned documents so navigation and hydration
|
|
8
|
+
* can read one shared document payload consistently.
|
|
9
|
+
*/
|
|
10
|
+
buildRouterPageDataScript(pageProps) {
|
|
11
|
+
const safeJson = JSON.stringify(pageProps || {}).replace(/</g, "\\u003c");
|
|
12
|
+
return `<script id="__ECO_PAGE_DATA__" type="application/json">${safeJson}<\/script>`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Builds the serialized page-props payload embedded into the final HTML.
|
|
16
|
+
*
|
|
17
|
+
* The document payload is intentionally narrower than the full server render
|
|
18
|
+
* input: only routing data, public page props, and explicitly allowed locals are
|
|
19
|
+
* exposed to the browser.
|
|
20
|
+
*/
|
|
21
|
+
buildSerializedPageProps(options) {
|
|
22
|
+
return {
|
|
23
|
+
...options.pageProps,
|
|
24
|
+
params: options.params,
|
|
25
|
+
query: options.query,
|
|
26
|
+
...options.safeLocals && { locals: options.safeLocals }
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Safely extracts the declared subset of locals for client-side hydration.
|
|
31
|
+
*
|
|
32
|
+
* On dynamic pages with `cache: 'dynamic'`, middleware populates `locals` with
|
|
33
|
+
* request-scoped data (e.g., session). Only keys explicitly declared via
|
|
34
|
+
* `Page.requires` are serialized to the client so sensitive request-only data
|
|
35
|
+
* is not leaked into hydration payloads by default.
|
|
36
|
+
*
|
|
37
|
+
* On static pages, `locals` is a Proxy that throws `LocalsAccessError` on access
|
|
38
|
+
* to prevent accidental use. This method safely detects that case and returns
|
|
39
|
+
* `undefined` instead of throwing.
|
|
40
|
+
*/
|
|
41
|
+
getSerializableLocals(locals, requiredLocals) {
|
|
42
|
+
try {
|
|
43
|
+
if (!locals) {
|
|
44
|
+
return void 0;
|
|
45
|
+
}
|
|
46
|
+
const requiredKeys = requiredLocals ? Array.isArray(requiredLocals) ? requiredLocals : [requiredLocals] : [];
|
|
47
|
+
if (requiredKeys.length === 0) {
|
|
48
|
+
return void 0;
|
|
49
|
+
}
|
|
50
|
+
const serializedLocals = Object.fromEntries(
|
|
51
|
+
requiredKeys.filter((key) => Object.prototype.hasOwnProperty.call(locals, key)).map((key) => [key, locals[key]])
|
|
52
|
+
);
|
|
53
|
+
if (Object.keys(serializedLocals).length > 0) {
|
|
54
|
+
return serializedLocals;
|
|
55
|
+
}
|
|
56
|
+
return void 0;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof LocalsAccessError) {
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export {
|
|
66
|
+
ReactPagePayloadService
|
|
67
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { EcoComponent, EcoComponentConfig } from '@ecopages/core';
|
|
2
|
+
/**
|
|
3
|
+
* Walks a component config tree once, including nested layout configs and
|
|
4
|
+
* dependency component configs.
|
|
5
|
+
*
|
|
6
|
+
* The shared React integration code performs several different analyses over the
|
|
7
|
+
* same config graph. Centralizing the traversal keeps cycle handling and graph
|
|
8
|
+
* shape assumptions in one place instead of repeating them in the renderer and
|
|
9
|
+
* services.
|
|
10
|
+
*/
|
|
11
|
+
export declare function walkConfigTree(config: EcoComponentConfig | undefined, visitor: (config: EcoComponentConfig) => void, visited?: Set<EcoComponentConfig>): void;
|
|
12
|
+
/**
|
|
13
|
+
* Walks a forest of root component configs using one shared visited set.
|
|
14
|
+
*
|
|
15
|
+
* This is useful when a page contributes multiple config roots, such as a page
|
|
16
|
+
* config plus a resolved layout config, and duplicate nested nodes should still
|
|
17
|
+
* be processed only once.
|
|
18
|
+
*/
|
|
19
|
+
export declare function walkConfigForest(configs: Iterable<EcoComponentConfig | undefined>, visitor: (config: EcoComponentConfig) => void): void;
|
|
20
|
+
/**
|
|
21
|
+
* Collects values from a config tree while preserving the shared traversal and
|
|
22
|
+
* cycle protection behavior used across the React integration.
|
|
23
|
+
*/
|
|
24
|
+
export declare function collectFromConfigTree<T>(config: EcoComponentConfig | undefined, collector: (config: EcoComponentConfig) => T[]): T[];
|
|
25
|
+
/**
|
|
26
|
+
* Collects values from multiple config roots with one shared visited set.
|
|
27
|
+
*/
|
|
28
|
+
export declare function collectFromConfigForest<T>(configs: Iterable<EcoComponentConfig | undefined>, collector: (config: EcoComponentConfig) => T[]): T[];
|
|
29
|
+
/**
|
|
30
|
+
* Returns true when any node in the config tree matches the predicate.
|
|
31
|
+
*/
|
|
32
|
+
export declare function someInConfigTree(config: EcoComponentConfig | undefined, predicate: (config: EcoComponentConfig) => boolean): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Reads config roots from partial components while tolerating undefined config.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getComponentConfigs(components: Partial<EcoComponent>[]): Array<EcoComponentConfig | undefined>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function walkConfigTree(config, visitor, visited = /* @__PURE__ */ new Set()) {
|
|
2
|
+
if (!config || visited.has(config)) {
|
|
3
|
+
return;
|
|
4
|
+
}
|
|
5
|
+
visited.add(config);
|
|
6
|
+
visitor(config);
|
|
7
|
+
if (config.layout?.config) {
|
|
8
|
+
walkConfigTree(config.layout.config, visitor, visited);
|
|
9
|
+
}
|
|
10
|
+
for (const component of config.dependencies?.components ?? []) {
|
|
11
|
+
walkConfigTree(component.config, visitor, visited);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function walkConfigForest(configs, visitor) {
|
|
15
|
+
const visited = /* @__PURE__ */ new Set();
|
|
16
|
+
for (const config of configs) {
|
|
17
|
+
walkConfigTree(config, visitor, visited);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function collectFromConfigTree(config, collector) {
|
|
21
|
+
const values = [];
|
|
22
|
+
walkConfigTree(config, (node) => {
|
|
23
|
+
values.push(...collector(node));
|
|
24
|
+
});
|
|
25
|
+
return values;
|
|
26
|
+
}
|
|
27
|
+
function collectFromConfigForest(configs, collector) {
|
|
28
|
+
const values = [];
|
|
29
|
+
walkConfigForest(configs, (node) => {
|
|
30
|
+
values.push(...collector(node));
|
|
31
|
+
});
|
|
32
|
+
return values;
|
|
33
|
+
}
|
|
34
|
+
function someInConfigTree(config, predicate) {
|
|
35
|
+
let matched = false;
|
|
36
|
+
walkConfigTree(config, (node) => {
|
|
37
|
+
if (matched) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
matched = predicate(node);
|
|
41
|
+
});
|
|
42
|
+
return matched;
|
|
43
|
+
}
|
|
44
|
+
function getComponentConfigs(components) {
|
|
45
|
+
return components.map((component) => component.config);
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
collectFromConfigForest,
|
|
49
|
+
collectFromConfigTree,
|
|
50
|
+
getComponentConfigs,
|
|
51
|
+
someInConfigTree,
|
|
52
|
+
walkConfigForest,
|
|
53
|
+
walkConfigTree
|
|
54
|
+
};
|
|
@@ -29,7 +29,7 @@ export declare function normalizeDeclaredModuleSources(modules?: string[]): stri
|
|
|
29
29
|
* Recursively walks a component config tree (including layouts and nested
|
|
30
30
|
* `dependencies.components`) to collect all declared module sources.
|
|
31
31
|
*/
|
|
32
|
-
export declare function collectDeclaredModulesInConfig(config: EcoComponentConfig | undefined
|
|
32
|
+
export declare function collectDeclaredModulesInConfig(config: EcoComponentConfig | undefined): string[];
|
|
33
33
|
/**
|
|
34
34
|
* Collects declared module sources from an already imported page module.
|
|
35
35
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectFromConfigTree } from "./component-config-traversal.js";
|
|
1
2
|
function parseDeclaredModuleSource(value) {
|
|
2
3
|
const source = value.trim();
|
|
3
4
|
if (source.length === 0) return void 0;
|
|
@@ -16,21 +17,8 @@ function normalizeDeclaredModuleSources(modules) {
|
|
|
16
17
|
}
|
|
17
18
|
return Array.from(seen);
|
|
18
19
|
}
|
|
19
|
-
function collectDeclaredModulesInConfig(config
|
|
20
|
-
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
|
-
visited.add(config);
|
|
24
|
-
const declarations = normalizeDeclaredModuleSources(config.dependencies?.modules);
|
|
25
|
-
if (config.layout?.config) {
|
|
26
|
-
declarations.push(...collectDeclaredModulesInConfig(config.layout.config, visited));
|
|
27
|
-
}
|
|
28
|
-
for (const component of config.dependencies?.components ?? []) {
|
|
29
|
-
if (component.config) {
|
|
30
|
-
declarations.push(...collectDeclaredModulesInConfig(component.config, visited));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return declarations;
|
|
20
|
+
function collectDeclaredModulesInConfig(config) {
|
|
21
|
+
return collectFromConfigTree(config, (node) => normalizeDeclaredModuleSources(node.dependencies?.modules));
|
|
34
22
|
}
|
|
35
23
|
function collectPageDeclaredModulesFromModule(pageModule) {
|
|
36
24
|
const declarations = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
5
|
+
import { dynamic } from "./dynamic.js";
|
|
6
|
+
function createDeferredImport() {
|
|
7
|
+
let resolve;
|
|
8
|
+
const promise = new Promise((innerResolve) => {
|
|
9
|
+
resolve = innerResolve;
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
promise,
|
|
13
|
+
resolve
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe("dynamic", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup();
|
|
19
|
+
});
|
|
20
|
+
it("returns a browser lazy component that resolves through Suspense", async () => {
|
|
21
|
+
const deferredImport = createDeferredImport();
|
|
22
|
+
const DynamicComponent = dynamic(() => deferredImport.promise);
|
|
23
|
+
render(
|
|
24
|
+
/* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx("span", { children: "Loading dynamic component" }), children: /* @__PURE__ */ jsx(DynamicComponent, {}) })
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText("Loading dynamic component")).toBeTruthy();
|
|
27
|
+
deferredImport.resolve({
|
|
28
|
+
default: () => /* @__PURE__ */ jsx("span", { children: "Dynamic content" })
|
|
29
|
+
});
|
|
30
|
+
expect(await screen.findByText("Dynamic content")).toBeTruthy();
|
|
31
|
+
expect(screen.queryByText("Loading dynamic component")).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createHydrationScript } from "./hydration-scripts.js";
|
|
3
|
+
const routerAdapter = {
|
|
4
|
+
name: "eco-router",
|
|
5
|
+
bundle: {
|
|
6
|
+
importPath: "/assets/router.js",
|
|
7
|
+
outputName: "router",
|
|
8
|
+
externals: []
|
|
9
|
+
},
|
|
10
|
+
importMapKey: "@ecopages/react-router",
|
|
11
|
+
components: {
|
|
12
|
+
router: "EcoRouter",
|
|
13
|
+
pageContent: "PageContent"
|
|
14
|
+
},
|
|
15
|
+
getRouterProps: (page, props) => `{ page: ${page}, pageProps: ${props} }`
|
|
16
|
+
};
|
|
17
|
+
function createModuleUrl(source) {
|
|
18
|
+
return `data:text/javascript;base64,${btoa(source)}`;
|
|
19
|
+
}
|
|
20
|
+
async function importModule(moduleUrl) {
|
|
21
|
+
await import(
|
|
22
|
+
/* @vite-ignore */
|
|
23
|
+
moduleUrl
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
function createRuntimeModules() {
|
|
27
|
+
const reactImportPath = createModuleUrl("export const createElement = (...args) => ({ args });");
|
|
28
|
+
const reactDomClientImportPath = createModuleUrl(`
|
|
29
|
+
export const hydrateRoot = (container, tree, options) => {
|
|
30
|
+
const runtime = window.__ECO_REACT_HYDRATION_TEST__;
|
|
31
|
+
runtime.hydrateCalls.push({
|
|
32
|
+
containerTag: container.tagName,
|
|
33
|
+
hasRecoverableErrorHandler: typeof options?.onRecoverableError === "function",
|
|
34
|
+
tree,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
render() {},
|
|
39
|
+
unmount() {
|
|
40
|
+
runtime.unmountCount += 1;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
`);
|
|
45
|
+
const importPath = createModuleUrl("export default function Page() { return null; }");
|
|
46
|
+
const routerImportPath = createModuleUrl(`
|
|
47
|
+
export function EcoRouter(props) {
|
|
48
|
+
return props;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function PageContent() {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
`);
|
|
55
|
+
return {
|
|
56
|
+
importPath,
|
|
57
|
+
reactImportPath,
|
|
58
|
+
reactDomClientImportPath,
|
|
59
|
+
routerImportPath
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
describe("createHydrationScript browser execution", () => {
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
document.body.innerHTML = "";
|
|
65
|
+
const testWindow = window;
|
|
66
|
+
delete testWindow.__ECO_PAGES__;
|
|
67
|
+
delete testWindow.__ECO_REACT_HYDRATION_TEST__;
|
|
68
|
+
});
|
|
69
|
+
it("registers router ownership and cleanup when the browser hydration bootstrap runs", async () => {
|
|
70
|
+
const runtimeModules = createRuntimeModules();
|
|
71
|
+
const testWindow = window;
|
|
72
|
+
testWindow.__ECO_REACT_HYDRATION_TEST__ = {
|
|
73
|
+
hydrateCalls: [],
|
|
74
|
+
claimedOwners: [],
|
|
75
|
+
releasedOwners: [],
|
|
76
|
+
registrations: [],
|
|
77
|
+
unmountCount: 0
|
|
78
|
+
};
|
|
79
|
+
testWindow.__ECO_PAGES__ = {
|
|
80
|
+
navigation: {
|
|
81
|
+
getOwnerState: () => ({
|
|
82
|
+
owner: "html",
|
|
83
|
+
canHandleSpaNavigation: false
|
|
84
|
+
}),
|
|
85
|
+
register: (registration) => {
|
|
86
|
+
testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations.push(registration);
|
|
87
|
+
},
|
|
88
|
+
claimOwnership: (owner) => {
|
|
89
|
+
testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners.push(owner);
|
|
90
|
+
},
|
|
91
|
+
releaseOwnership: (owner) => {
|
|
92
|
+
testWindow.__ECO_REACT_HYDRATION_TEST__?.releasedOwners.push(owner);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
document.body.innerHTML = `<script id="__ECO_PAGE_DATA__" type="application/json">${JSON.stringify({
|
|
97
|
+
title: "Hello React",
|
|
98
|
+
locals: { theme: "dark" }
|
|
99
|
+
})}<\/script>`;
|
|
100
|
+
const script = createHydrationScript({
|
|
101
|
+
...runtimeModules,
|
|
102
|
+
isDevelopment: true,
|
|
103
|
+
isMdx: false,
|
|
104
|
+
router: routerAdapter
|
|
105
|
+
});
|
|
106
|
+
await importModule(createModuleUrl(script));
|
|
107
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls).toHaveLength(1);
|
|
108
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls[0]?.containerTag).toBe("BODY");
|
|
109
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.hydrateCalls[0]?.hasRecoverableErrorHandler).toBe(true);
|
|
110
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.claimedOwners).toEqual(["react-router"]);
|
|
111
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.registrations).toHaveLength(1);
|
|
112
|
+
expect(typeof testWindow.__ECO_PAGES__?.react?.cleanupPageRoot).toBe("function");
|
|
113
|
+
expect(testWindow.__ECO_PAGES__?.page).toEqual({
|
|
114
|
+
module: runtimeModules.importPath,
|
|
115
|
+
props: {
|
|
116
|
+
title: "Hello React",
|
|
117
|
+
locals: { theme: "dark" }
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await testWindow.__ECO_PAGES__?.react?.cleanupPageRoot?.();
|
|
121
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.unmountCount).toBe(1);
|
|
122
|
+
expect(testWindow.__ECO_REACT_HYDRATION_TEST__?.releasedOwners).toEqual(["react-router"]);
|
|
123
|
+
expect(testWindow.__ECO_PAGES__?.page).toBeUndefined();
|
|
124
|
+
expect(testWindow.__ECO_PAGES__?.react?.pageRoot).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|