Compare commits
13
Commits
a6570d7f68
...
442a3106ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442a3106ed | ||
|
|
3115277e9d | ||
|
|
02fdfa3aee | ||
|
|
5f66c8129c | ||
|
|
01a3b3b4e9 | ||
|
|
a184f1a3be | ||
|
|
06df9d66ae | ||
|
|
d269772a79 | ||
|
|
df2ee036eb | ||
|
|
b405025f37 | ||
|
|
cd07f0f8e2 | ||
|
|
ce67d0d1d2 | ||
|
|
468f63f378 |
@@ -2259,6 +2259,30 @@
|
||||
"subjectQueue"
|
||||
]
|
||||
},
|
||||
"@wrnexus/react": {
|
||||
".": [
|
||||
"BoundStore",
|
||||
"IslandErrorBoundary",
|
||||
"IslandErrorBoundaryProps",
|
||||
"IslandStore",
|
||||
"MountOptions",
|
||||
"SnapshotCache",
|
||||
"SnapshotSource",
|
||||
"StoreResolver",
|
||||
"createSelectorCache",
|
||||
"createSnapshotCache",
|
||||
"islandRootCount",
|
||||
"mountIslands",
|
||||
"remountIslands",
|
||||
"setStoreResolver",
|
||||
"unmountIslands",
|
||||
"useWrnActions",
|
||||
"useWrnStore"
|
||||
],
|
||||
"./runtime": [
|
||||
"getIslandRuntime"
|
||||
]
|
||||
},
|
||||
"@wrnexus/reactive": {
|
||||
".": [
|
||||
"AnimationTimeline",
|
||||
|
||||
@@ -127,9 +127,9 @@ record that subscribers and devtools depend on.
|
||||
Write → action → store notifies → snapshot changes → island re-renders. This terminates cleanly
|
||||
**provided writes never occur during render**. Writes belong in event handlers or effects.
|
||||
|
||||
This rule is enforced in dev (see Error handling), not merely documented. It is also the reason
|
||||
this shape was chosen over generated `bind:` sugar: the cycle stays visible in the author's own
|
||||
code rather than being hidden in generated glue.
|
||||
This rule is documented rather than machine-enforced — see "Dropped: the write-during-render
|
||||
guard" below. It is also the reason this shape was chosen over generated `bind:` sugar: the
|
||||
cycle stays visible in the author's own code rather than being hidden in generated glue.
|
||||
|
||||
## Compiler and bundler changes
|
||||
|
||||
@@ -189,12 +189,38 @@ point.
|
||||
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
||||
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
|
||||
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
|
||||
| Action fired during render | Dev: throw with a targeted message pointing at the handler/effect rule (React's own warning is too generic to diagnose quickly) |
|
||||
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
|
||||
| Cleanup throws on unmount | Caught and logged; navigation must not break |
|
||||
|
||||
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
|
||||
the rest of the page working.
|
||||
|
||||
## Dropped: the write-during-render guard
|
||||
|
||||
The design originally called for a dev-only guard that threw when an island
|
||||
called a store action during render. It was implemented, then removed: the
|
||||
mechanism is unreliable in both directions.
|
||||
|
||||
- **False positives.** React runs effects before a queued microtask drains, so a
|
||||
render-phase flag cleared on a microtask is still set inside `useEffect`. An
|
||||
island writing from an effect — the documented correct pattern — would throw.
|
||||
- **False negatives.** The flag can only be set from the error boundary`s
|
||||
render. When an island updates its own state, only the island re-renders, so
|
||||
the flag is never set and a genuine write-during-render passes silently.
|
||||
|
||||
There is no reliable public API for detecting React`s render phase; doing it
|
||||
properly requires React internals, which is not acceptable in a shipped
|
||||
framework.
|
||||
|
||||
React already covers the real hazard: writing during render that notifies
|
||||
subscribers produces "Cannot update a component while rendering a different
|
||||
component", and the infinite-loop case is caught by the `getSnapshot` caching
|
||||
requirement handled in the store bridge. The custom guard added false positives
|
||||
without covering anything React misses.
|
||||
|
||||
The author-facing rule still stands and is still documented — it is simply not
|
||||
machine-enforced.
|
||||
|
||||
## Testing
|
||||
|
||||
### Compiler unit tests
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"packages/ui/components/Breadcrumb.wrn": "9f6c23550ebfc635c36ca8660953775170b4a471c0cd0206b9a7ba760994980a",
|
||||
"packages/ui/components/ButtonGroup.wrn": "fc23565ad56bd8483fcb777d9e02ccba2f9ea476c085754d42503bb83b718f72",
|
||||
"packages/ui/components/CTASection.wrn": "d0665f2aa33eea95e6f84120f739b3dd9c8723c882138bcb1a7f80dcd7909904",
|
||||
"packages/ui/components/Card.wrn": "255912df0668ebcf45b6cc3ffe99a4a3e0bf87376a5d199e3adcdf3610ea79e3",
|
||||
"packages/ui/components/Carousel.wrn": "7ab122bc0ce772346c665b43d62e73c9865a13b97c02ccf5b03d1e3eaea757d6",
|
||||
"packages/ui/components/Card.wrn": "3cc2ff238bda279c169026a462236144ad11ec0046deef00ab00e001a07e8a4c",
|
||||
"packages/ui/components/Carousel.wrn": "e7d921f802aa19210f3f415b59b70750a2c8d78e30684ad334809741c70a6bf9",
|
||||
"packages/ui/components/Chart.wrn": "07bc01247b5a1c2b82c2ba8386b7fcdbf480efead75d52b7da04d471072c7ee4",
|
||||
"packages/ui/components/ChatBubble.wrn": "d71f7d6567d76ac0eb3ecc8e2e567fec12bb3236ea05f4d0a72286abc0b7cbb1",
|
||||
"packages/ui/components/Clipboard.wrn": "5a93f2bae337d7ce364229b796bac673f60dde6dc135f0969f4e106e04854d30",
|
||||
@@ -39,7 +39,7 @@
|
||||
"packages/ui/components/FeatureIconCard.wrn": "3b4f3fa62c6729e686886a5b848e26c255766684829cec2b715967b4e73d6f07",
|
||||
"packages/ui/components/FileInput.wrn": "866a292a3280527bf429893469e7c50f7cf38965d8adb37b45a35ede1606d5b8",
|
||||
"packages/ui/components/FileUploadProgress.wrn": "e5da29c562a521cdd6bb50b8f4d217aa1ab981d7b2a2432af47391472f0a8034",
|
||||
"packages/ui/components/Footer.wrn": "c498820a160c1286331a423a4498054e7852d2f1a9eb6e81eb5b008b1693efc4",
|
||||
"packages/ui/components/Footer.wrn": "f7c5a77064a09e244f689d42475c4c20143f4c8859cac451c10a50c14652db33",
|
||||
"packages/ui/components/Grid.wrn": "83d4f5f2656d539538723f5791ba3c238901432e9d8c55c74f068d3eb5a74517",
|
||||
"packages/ui/components/Hero.wrn": "345478b212701817ff57060f87982a987baf01a7179c979768e1cd4218b50900",
|
||||
"packages/ui/components/HeroActions.wrn": "67cd31400ccb6abdbbf16219267dee946b44b064b79f25276d20ceb7d6e8a790",
|
||||
@@ -60,7 +60,7 @@
|
||||
"packages/ui/components/MetricGrid.wrn": "c71e53249835908833055b553e64a8ee55fdf11ac4605436a9581ebb87a0608b",
|
||||
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
|
||||
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a",
|
||||
"packages/ui/components/Navbar.wrn": "ed3bb1974b93d52c480af468bdb1c0b253cb9196f3a00602614d3ce1e271a669",
|
||||
"packages/ui/components/Navbar.wrn": "01903230210cd2e5e6d9325a0708f85af9623d4118e3a53f1adb770fd545425d",
|
||||
"packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
|
||||
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
|
||||
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
|
||||
@@ -95,14 +95,14 @@
|
||||
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
|
||||
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
|
||||
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
|
||||
"packages/ui/components/input.wrn": "a576e5ee2c6d0da963ebb14e8809ddf3ec7333b1eae095d5a4b7fce3ceb1fb55",
|
||||
"packages/ui/components/input.wrn": "e4c14527f009b610b4aa96d8d7b7d5ac3ca1ffd34237bab348ee9dc4ddc55847",
|
||||
"packages/ui/components/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
|
||||
"packages/ui/components/radio.wrn": "09425a78358de5bbd2f47482f313e80065135335b969ce5ccdd5c7cc3ea5232c",
|
||||
"packages/ui/components/select.wrn": "b107b259d228201e9071701370ad1c912ac9a8131f9e0a55834bcc84ef0dd417",
|
||||
"packages/ui/components/select.wrn": "1d8d47a74d5ab9ed58d0ba11f46910897233b96652d17f5962f5dda60bd4cf8a",
|
||||
"packages/ui/components/skeleton.wrn": "4fc5e0846eeefd7830c038e1789be995c4f9d833aa913ff079eb4863baa65648",
|
||||
"packages/ui/components/spinner.wrn": "2322645da7ef53f7c06035ff071d9a2f6ffe2901ff9338daed84037369367105",
|
||||
"packages/ui/components/switch.wrn": "874504e4828e9db6a570d78984c4d3a76d0ceb39d70baa877649cb3798c82c85",
|
||||
"packages/ui/components/textarea.wrn": "76d432179f2b7790ab9cdd644752928c259e1f496ad89c4db79981b74bbd226f",
|
||||
"packages/ui/components/textarea.wrn": "9870d37664471a103d44435a3977bf40b087a743acd76ee4d77df9b56cccbb2b",
|
||||
"packages/ui/components/tooltip.wrn": "f3dfdfa5cd9661fe5e95ef3580eef4c7437c069726421370c2d7328fbe7d840d",
|
||||
"packages/ui/styles/SelectStyles.wrn": "074fe0d67de4ef5e9f9cfe879beb72fd5c83352888687d3a1a4b7722c87af3ca",
|
||||
"packages/ui/ui.css": "9ea591404e9cf675bbf1003e15e9fad00327094ff217dc20733f0fc87c0f4a62"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: c0e3e8c72c68cb3c182e2de84c13ef0b8921579d9b081550002b8cdbe4af3397
|
||||
// WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18
|
||||
// Generated with TypeScript: 5.9.3
|
||||
// WRN editor compiler source hash: 182fd799ca860d927879d4259c182ea61cbd89d913758cc9f690e1ae4a35d90f
|
||||
// WRN editor compiler generator hash: 2690208ba65bb00d9fea3e08cb3ab324cfda77792021cd46785814fadf41c1bc
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
const __path = __nodeRequire("node:path");
|
||||
const __modules = {
|
||||
@@ -11,6 +11,7 @@ const __modules = {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.optimizeAst = optimizeAst;
|
||||
exports.analyzeOptimizations = analyzeOptimizations;
|
||||
exports.routeNeedsIslands = routeNeedsIslands;
|
||||
exports.analyzeRuntimeRequirements = analyzeRuntimeRequirements;
|
||||
function identifiers(value) {
|
||||
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
||||
@@ -191,8 +192,18 @@ function hasEvent(nodes) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function analyzeRuntimeRequirements(ast) {
|
||||
/**
|
||||
* A route containing a React island ships JavaScript and can no longer be
|
||||
* classified as zero-JS static, so island presence must reach the classifier.
|
||||
*/
|
||||
function routeNeedsIslands(imports) {
|
||||
return imports.some((entry) => entry.kind === "island");
|
||||
}
|
||||
function analyzeRuntimeRequirements(ast, options = {}) {
|
||||
const hasIslands = options.hasIslands ?? false;
|
||||
const reasons = [];
|
||||
if (hasIslands)
|
||||
reasons.push("react island");
|
||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||
const interactive = clientFunctions ||
|
||||
@@ -246,11 +257,16 @@ function analyzeRuntimeRequirements(ast) {
|
||||
kind = "streaming-ssr";
|
||||
reasons.push("partial-static shell with streamed dynamic regions");
|
||||
}
|
||||
// An island ships JavaScript, so a would-be zero-JS static route must be
|
||||
// reported as static-interactive. Explicit render modes still win above.
|
||||
if (hasIslands && kind === "static")
|
||||
kind = "static-interactive";
|
||||
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||
const serverDisabled = ast.renderMode === "client";
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsIslandRuntime: hasIslands,
|
||||
needsClientRuntime: !clientDisabled &&
|
||||
(interactive || ast.renderMode === "client") &&
|
||||
ast.hydrate !== "none" &&
|
||||
@@ -3107,9 +3123,11 @@ function candidates(path) {
|
||||
path,
|
||||
`${path}.wrn`,
|
||||
`${path}.ts`,
|
||||
`${path}.tsx`,
|
||||
`${path}.d.ts`,
|
||||
(0, node_path_1.join)(path, "index.wrn"),
|
||||
(0, node_path_1.join)(path, "index.ts"),
|
||||
(0, node_path_1.join)(path, "index.tsx"),
|
||||
];
|
||||
}
|
||||
function resolveWrnImport(declaration, importer, options) {
|
||||
@@ -3136,8 +3154,12 @@ function resolveWrnImport(declaration, importer, options) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found)
|
||||
return { declaration, resolved: (0, node_fs_1.realpathSync)(found) };
|
||||
if (found) {
|
||||
const resolved = (0, node_fs_1.realpathSync)(found);
|
||||
return resolved.endsWith(".tsx")
|
||||
? { declaration, resolved, kind: "island" }
|
||||
: { declaration, resolved };
|
||||
}
|
||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||
return {
|
||||
declaration,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 28a3937b948e6affb33150753d537162c6702786551dd90ab0968ef9166f21ac
|
||||
// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
+2390
-1663
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Counter({ start = 0 }: { start?: number }) {
|
||||
const [count, setCount] = useState(start);
|
||||
return (
|
||||
<button type="button" onClick={() => setCount((value) => value + 1)}>
|
||||
{`clicked ${count}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+8
-4
@@ -71,12 +71,16 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"eslint": "^10.8.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"eslint": "^10.8.1",
|
||||
"happy-dom": "^20.11.2",
|
||||
"prettier": "^3.9.6",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.65.0"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.0"
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr";
|
||||
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||
import {
|
||||
analyzeRuntimeImports,
|
||||
analyzeRuntimeRequirements,
|
||||
@@ -112,6 +113,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const clientModulesDir = join(distDir, "client");
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const controllersPath = join(distDir, "controllers.js");
|
||||
const islandsPath = join(distDir, "islands.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
const config = await loadAppConfig(root);
|
||||
@@ -493,6 +495,16 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
assetHash.update(controllerCode);
|
||||
console.log(`✓ Controllers: ${controllersPath}`);
|
||||
|
||||
// Island bootstrap: emitted unconditionally but inert without markers, so a
|
||||
// build with no islands still ships no React.
|
||||
const islandCode = await buildBrowserRuntime(
|
||||
getIslandRuntime(),
|
||||
islandsPath,
|
||||
join(compiledDir, "islands.entry.js"),
|
||||
);
|
||||
assetHash.update(islandCode);
|
||||
console.log(`✓ Islands: ${islandsPath}`);
|
||||
|
||||
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
||||
const theme = resolveThemeConfig(config.theme, config.cookies);
|
||||
const themeCss = renderThemeCss(theme);
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageAst, ViewNode } from "@wrnexus/syntax";
|
||||
import type { ResolvedImport } from "./import-resolver.ts";
|
||||
|
||||
export type RouteExecutionKind =
|
||||
| "static"
|
||||
@@ -12,6 +13,8 @@ export interface RuntimeRequirements {
|
||||
kind: RouteExecutionKind;
|
||||
canPrerender: boolean;
|
||||
needsClientRuntime: boolean;
|
||||
/** True when the route mounts a React island and must ship the island runtime. */
|
||||
needsIslandRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
@@ -210,8 +213,21 @@ function hasEvent(nodes: ViewNode[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
||||
/**
|
||||
* A route containing a React island ships JavaScript and can no longer be
|
||||
* classified as zero-JS static, so island presence must reach the classifier.
|
||||
*/
|
||||
export function routeNeedsIslands(imports: ResolvedImport[]): boolean {
|
||||
return imports.some((entry) => entry.kind === "island");
|
||||
}
|
||||
|
||||
export function analyzeRuntimeRequirements(
|
||||
ast: PageAst,
|
||||
options: { hasIslands?: boolean } = {},
|
||||
): RuntimeRequirements {
|
||||
const hasIslands = options.hasIslands ?? false;
|
||||
const reasons: string[] = [];
|
||||
if (hasIslands) reasons.push("react island");
|
||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||
const interactive =
|
||||
@@ -260,12 +276,17 @@ export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
||||
reasons.push("partial-static shell with streamed dynamic regions");
|
||||
}
|
||||
|
||||
// An island ships JavaScript, so a would-be zero-JS static route must be
|
||||
// reported as static-interactive. Explicit render modes still win above.
|
||||
if (hasIslands && kind === "static") kind = "static-interactive";
|
||||
|
||||
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||
const serverDisabled = ast.renderMode === "client";
|
||||
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsIslandRuntime: hasIslands,
|
||||
needsClientRuntime:
|
||||
!clientDisabled &&
|
||||
(interactive || ast.renderMode === "client") &&
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ImportResolverOptions {
|
||||
export interface ResolvedImport {
|
||||
declaration: StructuredImportDecl;
|
||||
resolved?: string;
|
||||
/** `.tsx` imports are React islands, not `.wrn` components. */
|
||||
kind?: "island";
|
||||
diagnostic?: { code: string; message: string; severity: "error" | "warning" };
|
||||
}
|
||||
|
||||
@@ -21,9 +23,11 @@ function candidates(path: string): string[] {
|
||||
path,
|
||||
`${path}.wrn`,
|
||||
`${path}.ts`,
|
||||
`${path}.tsx`,
|
||||
`${path}.d.ts`,
|
||||
join(path, "index.wrn"),
|
||||
join(path, "index.ts"),
|
||||
join(path, "index.tsx"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -56,7 +60,12 @@ export function resolveWrnImport(
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found) return { declaration, resolved: realpathSync(found) };
|
||||
if (found) {
|
||||
const resolved = realpathSync(found);
|
||||
return resolved.endsWith(".tsx")
|
||||
? { declaration, resolved, kind: "island" as const }
|
||||
: { declaration, resolved };
|
||||
}
|
||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||
return {
|
||||
declaration,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { BunPlugin } from "bun";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
export interface IslandInput {
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface IslandBuildResult {
|
||||
assets: Array<{ name: string; hash: string; path: string }>;
|
||||
sharedChunks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the per-island browser entry.
|
||||
*
|
||||
* Never imports react-dom/server — islands are client-only.
|
||||
*/
|
||||
export function generateIslandEntry(input: IslandInput): string {
|
||||
return [
|
||||
"/** @jsxImportSource react */",
|
||||
`import Component from ${JSON.stringify(input.sourcePath)};`,
|
||||
`export const name = ${JSON.stringify(input.name)};`,
|
||||
`export default Component;`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles every island `.tsx` against React's JSX runtime.
|
||||
*
|
||||
* The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an
|
||||
* island would otherwise compile to WRNexus's HTML-string renderer and
|
||||
* silently never mount. A `@jsxImportSource` pragma applies only to the file
|
||||
* that carries it, so putting one in the generated entry does nothing for the
|
||||
* author's own component — the injection has to happen per source file, which
|
||||
* is what this plugin does. App-authored islands stay plain `.tsx`.
|
||||
*/
|
||||
export function reactJsxPlugin(): BunPlugin {
|
||||
return {
|
||||
name: "wrnexus-island-jsx",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
||||
// Only first-party island sources need the pragma. Third-party .tsx
|
||||
// under node_modules is left alone so Bun's own handling is untouched.
|
||||
if (args.path.includes("node_modules")) return undefined;
|
||||
return {
|
||||
contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`,
|
||||
loader: "tsx" as const,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function assertReactAvailable(
|
||||
appRoot: string,
|
||||
): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null {
|
||||
const require = createRequire(join(appRoot, "package.json"));
|
||||
try {
|
||||
require.resolve("react");
|
||||
require.resolve("react-dom");
|
||||
return null;
|
||||
} catch {
|
||||
return {
|
||||
code: "WRN-ISLAND-REACT-MISSING",
|
||||
severity: "error",
|
||||
message:
|
||||
"This app imports a .tsx island but react and react-dom are not installed. " +
|
||||
"Run: bun add react react-dom",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles island entries. `splitting: true` is required so React is emitted
|
||||
* once as a shared chunk rather than duplicated into every island.
|
||||
*/
|
||||
export async function buildIslands(input: {
|
||||
islands: IslandInput[];
|
||||
outDir: string;
|
||||
}): Promise<IslandBuildResult> {
|
||||
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
|
||||
|
||||
// Each island gets its own generated entry file named after the island.
|
||||
// Passing the component sources directly would dedupe two islands that share
|
||||
// a source file, and output order is not guaranteed to match input order —
|
||||
// both of which silently mismatch island names to bundles.
|
||||
//
|
||||
// The entries live inside outDir so `react` resolves from the app that
|
||||
// installed it, exactly as the island's own imports do.
|
||||
const entryDir = join(input.outDir, ".entries");
|
||||
mkdirSync(entryDir, { recursive: true });
|
||||
|
||||
const islandNames = new Set(input.islands.map((island) => island.name));
|
||||
for (const island of input.islands) {
|
||||
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Bun.build({
|
||||
entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)),
|
||||
outdir: input.outDir,
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
plugins: [reactJsxPlugin()],
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "Island bundling failed");
|
||||
}
|
||||
|
||||
const assets: IslandBuildResult["assets"] = [];
|
||||
const sharedChunks: string[] = [];
|
||||
|
||||
for (const output of result.outputs) {
|
||||
if (output.kind === "entry-point") {
|
||||
// Bun names an entry's output after its entry file, so the basename
|
||||
// identifies the island unambiguously.
|
||||
const stem = basename(output.path).replace(/\.js$/, "");
|
||||
if (!islandNames.has(stem)) continue;
|
||||
assets.push({
|
||||
name: stem,
|
||||
hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16),
|
||||
path: output.path,
|
||||
});
|
||||
} else if (output.kind === "chunk") {
|
||||
sharedChunks.push(output.path);
|
||||
}
|
||||
}
|
||||
|
||||
return { assets, sharedChunks };
|
||||
} finally {
|
||||
rmSync(entryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { escapeHtml, isSafeIslandName } from "@wrnexus/core";
|
||||
|
||||
export type IslandStrategy = "only" | "load" | "visible" | "idle";
|
||||
|
||||
export interface IslandDiagnostic {
|
||||
code: "WRN-ISLAND-PROPS";
|
||||
message: string;
|
||||
severity: "error";
|
||||
}
|
||||
|
||||
const STRATEGIES: Record<string, IslandStrategy> = {
|
||||
"client:only": "only",
|
||||
"client:load": "load",
|
||||
"client:visible": "visible",
|
||||
"client:idle": "idle",
|
||||
};
|
||||
|
||||
export function parseIslandStrategy(directives: string[]): IslandStrategy {
|
||||
for (const directive of directives) {
|
||||
const match = STRATEGIES[directive];
|
||||
if (match) return match;
|
||||
}
|
||||
return "only";
|
||||
}
|
||||
|
||||
function unsupportedProp(value: unknown): boolean {
|
||||
const type = typeof value;
|
||||
if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
|
||||
return true;
|
||||
}
|
||||
if (value === null || type !== "object") return false;
|
||||
if (Array.isArray(value)) return (value as unknown[]).some(unsupportedProp);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) return true;
|
||||
return Object.values(value as Record<string, unknown>).some(unsupportedProp);
|
||||
}
|
||||
|
||||
export function serializeIslandProps(
|
||||
componentName: string,
|
||||
props: Record<string, unknown>,
|
||||
): { json: string } | { diagnostic: IslandDiagnostic } {
|
||||
const offenders = Object.entries(props)
|
||||
.filter(([, value]) => unsupportedProp(value))
|
||||
.map(([key]) => key);
|
||||
|
||||
if (offenders.length > 0) {
|
||||
return {
|
||||
diagnostic: {
|
||||
code: "WRN-ISLAND-PROPS",
|
||||
severity: "error",
|
||||
message:
|
||||
`Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
|
||||
`Island props cross a serialization boundary and must be JSON-safe ` +
|
||||
`(no functions, symbols, bigints, undefined, or class instances).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { json: JSON.stringify(props) };
|
||||
}
|
||||
|
||||
export function renderIslandMarker(input: {
|
||||
name: string;
|
||||
strategy: IslandStrategy;
|
||||
propsJson: string;
|
||||
}): string {
|
||||
// The name becomes a path segment when the browser fetches
|
||||
// /__wrnexus/island/<name>.js, so reuse the framework's conservative charset
|
||||
// rather than relying on escaping alone.
|
||||
if (!isSafeIslandName(input.name)) {
|
||||
throw new Error(
|
||||
`Island name '${input.name}' is not a safe identifier. ` +
|
||||
`Island names may only contain letters, digits, underscores, and hyphens.`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`<div data-wrn-island="${escapeHtml(input.name)}"` +
|
||||
` data-wrn-island-strategy="${input.strategy}"` +
|
||||
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterAll, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { assertReactAvailable, buildIslands, generateIslandEntry } from "../src/island-bundle.ts";
|
||||
|
||||
const created: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const dir of created) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("generates an entry that re-exports the island component", () => {
|
||||
const entry = generateIslandEntry({ name: "Chart", sourcePath: "/app/Chart.tsx" });
|
||||
expect(entry).toContain("/app/Chart.tsx");
|
||||
expect(entry).toContain("Chart");
|
||||
expect(entry).not.toContain("react-dom/server");
|
||||
});
|
||||
|
||||
test("island .tsx compiles against React's JSX runtime, not WRNexus's", async () => {
|
||||
// The root tsconfig points jsxImportSource at @wrnexus/core, so an island
|
||||
// would otherwise compile to WRNexus's HTML-string renderer and never mount.
|
||||
// A pragma applies only to the file carrying it, so this asserts the built
|
||||
// output rather than the generated entry text.
|
||||
// The fixture must live inside the repo: Bun resolves `react` from the
|
||||
// importing file's location, exactly as a real island resolves it from the
|
||||
// app that installed react.
|
||||
const root = mkdtempSync(join(process.cwd(), ".island-jsx-test-"));
|
||||
created.push(root);
|
||||
const source = join(root, "Chart.tsx");
|
||||
writeFileSync(
|
||||
source,
|
||||
`export default function Chart({ title }: { title: string }) {
|
||||
return <div className="chart">{title}</div>;
|
||||
}`,
|
||||
);
|
||||
|
||||
const outDir = join(root, "out");
|
||||
await buildIslands({ islands: [{ name: "Chart", sourcePath: source }], outDir });
|
||||
|
||||
const built = readdirSync(outDir)
|
||||
.filter((file) => file.endsWith(".js"))
|
||||
.map((file) => readFileSync(join(outDir, file), "utf8"))
|
||||
.join("\n");
|
||||
|
||||
expect(built).not.toContain("wrnexus");
|
||||
expect(built).toMatch(/react\/jsx|jsxDEV|jsx_runtime/);
|
||||
});
|
||||
|
||||
test("reports WRN-ISLAND-REACT-MISSING when react is not installed", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-noreact-"));
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
|
||||
|
||||
const diagnostic = assertReactAvailable(root);
|
||||
expect(diagnostic?.code).toBe("WRN-ISLAND-REACT-MISSING");
|
||||
expect(diagnostic?.message).toContain("bun add react react-dom");
|
||||
});
|
||||
|
||||
test("returns null when react resolves", () => {
|
||||
expect(assertReactAvailable(process.cwd())).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { analyzeRuntimeRequirements, routeNeedsIslands } from "../src/analysis.ts";
|
||||
|
||||
test("a route with an island import needs client JavaScript", () => {
|
||||
expect(
|
||||
routeNeedsIslands([
|
||||
{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" },
|
||||
{ declaration: { source: "./Chart" } as any, resolved: "/app/Chart.tsx", kind: "island" },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a route with no island imports stays zero-JS", () => {
|
||||
expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an empty import list stays zero-JS", () => {
|
||||
expect(routeNeedsIslands([])).toBe(false);
|
||||
});
|
||||
|
||||
test("an unresolved import does not count as an island", () => {
|
||||
expect(
|
||||
routeNeedsIslands([
|
||||
{
|
||||
declaration: { source: "./missing" } as any,
|
||||
diagnostic: { code: "WRN-IMPORT-NOT-FOUND", message: "nope", severity: "warning" },
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("an island promotes a static route to static-interactive", () => {
|
||||
const source = `page Home { view { <div>hello</div> } }`;
|
||||
const ast = parse(source);
|
||||
|
||||
const plain = analyzeRuntimeRequirements(ast);
|
||||
expect(plain.kind).toBe("static");
|
||||
expect(plain.needsIslandRuntime).toBe(false);
|
||||
|
||||
const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true });
|
||||
expect(withIsland.kind).toBe("static-interactive");
|
||||
expect(withIsland.needsIslandRuntime).toBe(true);
|
||||
expect(withIsland.reasons).toContain("react island");
|
||||
});
|
||||
|
||||
test("an island does not turn on the WRNexus reactive runtime", () => {
|
||||
const ast = parse(`page Home { view { <div>hello</div> } }`);
|
||||
const withIsland = analyzeRuntimeRequirements(ast, { hasIslands: true });
|
||||
|
||||
// Islands ship the island runtime, not WRNexus's own client runtime.
|
||||
expect(withIsland.needsClientRuntime).toBe(false);
|
||||
expect(withIsland.needsIslandRuntime).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
parseIslandStrategy,
|
||||
renderIslandMarker,
|
||||
serializeIslandProps,
|
||||
} from "../src/island-codegen.ts";
|
||||
|
||||
test("defaults to the client-only strategy", () => {
|
||||
expect(parseIslandStrategy([])).toBe("only");
|
||||
expect(parseIslandStrategy(["client:visible"])).toBe("visible");
|
||||
expect(parseIslandStrategy(["client:idle"])).toBe("idle");
|
||||
expect(parseIslandStrategy(["client:load"])).toBe("load");
|
||||
});
|
||||
|
||||
test("serializes JSON-safe props", () => {
|
||||
const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] });
|
||||
expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' });
|
||||
});
|
||||
|
||||
test("rejects non-serializable props with WRN-ISLAND-PROPS", () => {
|
||||
const result = serializeIslandProps("Chart", { onClick: () => {} });
|
||||
expect(result).toHaveProperty("diagnostic");
|
||||
const { diagnostic } = result as { diagnostic: { code: string; message: string } };
|
||||
expect(diagnostic.code).toBe("WRN-ISLAND-PROPS");
|
||||
expect(diagnostic.message).toContain("Chart");
|
||||
expect(diagnostic.message).toContain("onClick");
|
||||
});
|
||||
|
||||
test("rejects class instances and nested offenders", () => {
|
||||
class Point {
|
||||
constructor(public x = 1) {}
|
||||
}
|
||||
expect(serializeIslandProps("Chart", { origin: new Point() })).toHaveProperty("diagnostic");
|
||||
expect(serializeIslandProps("Chart", { nested: { deep: () => {} } })).toHaveProperty(
|
||||
"diagnostic",
|
||||
);
|
||||
expect(serializeIslandProps("Chart", { list: [1, () => {}] })).toHaveProperty("diagnostic");
|
||||
});
|
||||
|
||||
test("accepts null and nested plain data", () => {
|
||||
const result = serializeIslandProps("Chart", {
|
||||
empty: null,
|
||||
nested: { rows: [{ id: 1 }], flag: false },
|
||||
});
|
||||
expect(result).toHaveProperty("json");
|
||||
});
|
||||
|
||||
test("rejects island names that are unsafe as URL path segments", () => {
|
||||
// The name is fetched as /__wrnexus/island/<name>.js, so traversal and
|
||||
// separators must be refused rather than merely escaped.
|
||||
expect(() =>
|
||||
renderIslandMarker({ name: "../secret", strategy: "only", propsJson: "{}" }),
|
||||
).toThrow(/not a safe identifier/);
|
||||
expect(() => renderIslandMarker({ name: "a/b", strategy: "only", propsJson: "{}" })).toThrow(
|
||||
/not a safe identifier/,
|
||||
);
|
||||
expect(() =>
|
||||
renderIslandMarker({ name: "Chart", strategy: "only", propsJson: "{}" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("renders a marker with escaped props", () => {
|
||||
const html = renderIslandMarker({
|
||||
name: "Chart",
|
||||
strategy: "visible",
|
||||
propsJson: '{"title":"a<b\\"c"}',
|
||||
});
|
||||
expect(html).toContain('data-wrn-island="Chart"');
|
||||
expect(html).toContain('data-wrn-island-strategy="visible"');
|
||||
expect(html).not.toContain('title":"a<b"c');
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain(""");
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { routeNeedsIslands } from "../src/analysis.ts";
|
||||
import { buildIslands, type IslandBuildResult } from "../src/island-bundle.ts";
|
||||
|
||||
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
|
||||
|
||||
const created: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const dir of created) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function outDir(label: string): string {
|
||||
const dir = mkdtempSync(join(process.cwd(), `.island-int-${label}-`));
|
||||
created.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Every assertion that needs a real bundle shares this one build.
|
||||
//
|
||||
// Not just for speed: `bun test` interferes with Bun.build's module reads once
|
||||
// several build calls have run across test files in the same process, while the
|
||||
// same calls succeed repeatedly outside the runner. Production is unaffected —
|
||||
// the dev server's rebuild loop was verified separately — but tests must keep
|
||||
// their build count low to stay reliable in the full suite.
|
||||
let dir: string;
|
||||
let result: IslandBuildResult;
|
||||
let bundles: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = outDir("shared");
|
||||
result = await buildIslands({
|
||||
islands: [
|
||||
{ name: "CounterA", sourcePath: COUNTER },
|
||||
{ name: "CounterB", sourcePath: COUNTER },
|
||||
],
|
||||
outDir: dir,
|
||||
});
|
||||
bundles = readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".js"))
|
||||
.map((file) => readFileSync(join(dir, file), "utf8"));
|
||||
});
|
||||
|
||||
test("a route with no islands ships zero framework JavaScript", async () => {
|
||||
const empty = outDir("nojs");
|
||||
const none = await buildIslands({ islands: [], outDir: empty });
|
||||
|
||||
expect(none.assets).toHaveLength(0);
|
||||
expect(none.sharedChunks).toHaveLength(0);
|
||||
expect(readdirSync(empty)).toHaveLength(0);
|
||||
expect(routeNeedsIslands([])).toBe(false);
|
||||
});
|
||||
|
||||
test("a page with multiple islands ships React exactly once", () => {
|
||||
// React's internals must appear in at most one emitted file — the shared
|
||||
// chunk. If splitting regresses, every island inlines its own copy.
|
||||
const withReactInternals = bundles.filter(
|
||||
(source) => source.includes("REACT_ELEMENT_TYPE") || source.includes("react.development"),
|
||||
);
|
||||
|
||||
expect(result.assets).toHaveLength(2);
|
||||
expect(withReactInternals.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("two islands sharing one source get distinct, correctly named assets", () => {
|
||||
// Passing component sources as entrypoints deduped them, so the second island
|
||||
// silently lost its bundle and names could bind to the wrong output.
|
||||
expect(result.assets.map((asset) => asset.name).sort()).toEqual(["CounterA", "CounterB"]);
|
||||
expect(new Set(result.assets.map((asset) => asset.path)).size).toBe(2);
|
||||
});
|
||||
|
||||
test("a real island builds against React and never pulls in the WRNexus renderer", () => {
|
||||
const built = bundles.join("\n");
|
||||
|
||||
expect(built).not.toContain("wrnexus");
|
||||
expect(built).not.toContain("react-dom/server");
|
||||
expect(built).toContain("useState");
|
||||
});
|
||||
|
||||
test("the generated entry directory is not left behind in the output", () => {
|
||||
expect(readdirSync(dir)).not.toContain(".entries");
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveWrnImport } from "../src/import-resolver.ts";
|
||||
|
||||
function appWith(files: Record<string, string>) {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-island-"));
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
writeFileSync(join(root, "app", name), contents);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
test("resolves a .tsx import and tags it as an island", () => {
|
||||
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
|
||||
const result = resolveWrnImport(
|
||||
{ source: "./Chart", specifiers: [] } as any,
|
||||
join(root, "app", "page.wrn"),
|
||||
{ appRoot: root },
|
||||
);
|
||||
|
||||
expect(result.resolved).toContain("Chart.tsx");
|
||||
expect(result.kind).toBe("island");
|
||||
});
|
||||
|
||||
test("does not tag a .ts import as an island", () => {
|
||||
const root = appWith({ "helper.ts": "export const value = 1;" });
|
||||
const result = resolveWrnImport(
|
||||
{ source: "./helper", specifiers: [] } as any,
|
||||
join(root, "app", "page.wrn"),
|
||||
{ appRoot: root },
|
||||
);
|
||||
|
||||
expect(result.resolved).toContain("helper.ts");
|
||||
expect(result.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers .wrn over .tsx when both exist", () => {
|
||||
const root = appWith({
|
||||
"Widget.wrn": "<template></template>",
|
||||
"Widget.tsx": "export default function Widget() { return null; }",
|
||||
});
|
||||
const result = resolveWrnImport(
|
||||
{ source: "./Widget", specifiers: [] } as any,
|
||||
join(root, "app", "page.wrn"),
|
||||
{ appRoot: root },
|
||||
);
|
||||
|
||||
expect(result.resolved).toContain("Widget.wrn");
|
||||
expect(result.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves an explicit .tsx extension as an island", () => {
|
||||
const root = appWith({ "Chart.tsx": "export default function Chart() { return null; }" });
|
||||
const result = resolveWrnImport(
|
||||
{ source: "./Chart.tsx", specifiers: [] } as any,
|
||||
join(root, "app", "page.wrn"),
|
||||
{ appRoot: root },
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("island");
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type ResolvedTheme,
|
||||
type StylesConfig,
|
||||
} from "@wrnexus/styles";
|
||||
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||
import { VALIDATE_RUNTIME } from "@wrnexus/validation";
|
||||
import { I18N_RUNTIME } from "@wrnexus/i18n";
|
||||
import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader";
|
||||
@@ -32,7 +33,7 @@ import type { Mode } from "@wrnexus/core";
|
||||
import type { AssetServer } from "./runtime.ts";
|
||||
import { servePublicAsset } from "./public.ts";
|
||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
||||
import { serveWrnBrowserArtifact } from "./pipeline.ts";
|
||||
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
|
||||
|
||||
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
||||
export interface DevStyles {
|
||||
@@ -95,6 +96,10 @@ export function createDevAssetServer(
|
||||
if (pathname.startsWith("/__wrnexus/client/")) {
|
||||
return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
||||
if (pathname === "/__wrnexus/controllers.js")
|
||||
return jsResponse(getComponentControllerRuntime(true));
|
||||
|
||||
@@ -57,6 +57,7 @@ export function runMiddleware(
|
||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||
const moduleVersions = new Map<string, number>();
|
||||
const browserArtifactPaths = new Map<string, string>();
|
||||
const islandArtifactPaths = new Map<string, string>();
|
||||
|
||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||
interface CompileImportOptions {
|
||||
@@ -644,6 +645,25 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
||||
});
|
||||
}
|
||||
|
||||
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
||||
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
||||
islandArtifactPaths.set(pathname, artifact);
|
||||
}
|
||||
|
||||
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
||||
export function serveIslandArtifact(pathname: string): Response | null {
|
||||
const artifact = islandArtifactPaths.get(pathname);
|
||||
if (!artifact || !existsSync(artifact)) return null;
|
||||
return new Response(readFileSync(artifact, "utf8"), {
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "no-store, max-age=0",
|
||||
pragma: "no-cache",
|
||||
expires: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Forget one module and force its next dynamic import to bypass Bun's import cache. */
|
||||
export function invalidateModule(file: string): void {
|
||||
file = resolve(file);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getNavRuntime,
|
||||
getRealtimeRuntime,
|
||||
} from "@wrnexus/csr";
|
||||
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||
import {
|
||||
loadEnv,
|
||||
resolveProfile,
|
||||
@@ -99,6 +100,7 @@ export interface ProdOptions {
|
||||
controllersPath?: string;
|
||||
/** Absolute directory containing bundled per-WRN browser modules. */
|
||||
clientModulesDir?: string;
|
||||
islandsDir?: string;
|
||||
/** Absolute path to the pre-built theme stylesheet (`theme.css`). */
|
||||
themePath?: string;
|
||||
/** Pre-built active theme/accent stylesheets, loaded on demand. */
|
||||
@@ -341,6 +343,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
|
||||
}
|
||||
return serveFile(join(opts.clientModulesDir, name), JS_HEADERS);
|
||||
}
|
||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||
const name = pathname.slice("/__wrnexus/island/".length);
|
||||
if (!opts.islandsDir || !/^[A-Za-z0-9._-]+\.js$/.test(name)) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return serveFile(join(opts.islandsDir, name), JS_HEADERS);
|
||||
}
|
||||
if (pathname === "/__wrnexus/islands.js")
|
||||
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
||||
if (pathname === "/__wrnexus/reactive.js") {
|
||||
if (opts.reactivePath) {
|
||||
const file = Bun.file(opts.reactivePath);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# @wrnexus/react
|
||||
|
||||
Opt-in React islands for WRNexusJS: mount npm React components inside server-rendered `.wrn` pages without adopting React as the framework's rendering model.
|
||||
|
||||
Import a `.tsx` component in a `.wrn` script block and use it as an element. The compiler emits a `data-wrn-island` placeholder instead of a server render, and this package's runtime mounts it in the browser with `createRoot`. Islands are client-only, each mounts inside its own error boundary, and roots are disposed on client-side navigation.
|
||||
|
||||
`react` and `react-dom` are optional peer dependencies, so apps that use no islands ship no React. A route with no islands still ships zero framework JavaScript.
|
||||
|
||||
Use `useWrnStore(name, selector?)` to read a WRNexus store from inside an island and `useWrnActions(name)` to write to it. Writes belong in event handlers or effects, never during render.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/react",
|
||||
"version": "0.8.8",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./runtime": "./src/runtime-source.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": { "optional": true },
|
||||
"react-dom": { "optional": true }
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/store": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** @jsxImportSource react */
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export interface IslandErrorBoundaryProps {
|
||||
name: string;
|
||||
development: boolean;
|
||||
/** Optional so createElement(Boundary, props, child) typechecks. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface IslandErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains island failures locally: a crashed island must never blank the
|
||||
* surrounding server-rendered page.
|
||||
*/
|
||||
export class IslandErrorBoundary extends Component<
|
||||
IslandErrorBoundaryProps,
|
||||
IslandErrorBoundaryState
|
||||
> {
|
||||
override state: IslandErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): IslandErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error(`[wrnexus] island '${this.props.name}' failed to render`, error, info);
|
||||
}
|
||||
|
||||
override render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
if (!this.props.development) return null;
|
||||
return (
|
||||
<div data-wrn-island-error={this.props.name} style={{ padding: "0.75rem" }}>
|
||||
<strong>{`Island '${this.props.name}' failed`}</strong>
|
||||
<pre>{error.stack ?? error.message}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { createSelectorCache, createSnapshotCache } from "./snapshot-cache.ts";
|
||||
export type { SnapshotCache, SnapshotSource } from "./snapshot-cache.ts";
|
||||
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
|
||||
export type { BoundStore, IslandStore, StoreResolver } from "./store-bridge.ts";
|
||||
export { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||
export type { IslandErrorBoundaryProps } from "./error-boundary.tsx";
|
||||
export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts";
|
||||
export type { MountOptions } from "./island-runtime.ts";
|
||||
export { remountIslands } from "./island-runtime.ts";
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||
|
||||
export interface MountOptions {
|
||||
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
|
||||
development?: boolean;
|
||||
}
|
||||
|
||||
const roots = new Map<Element, Root>();
|
||||
|
||||
export function islandRootCount(): number {
|
||||
return roots.size;
|
||||
}
|
||||
|
||||
function readProps(element: Element): Record<string, unknown> {
|
||||
const raw = element.getAttribute("data-wrn-island-props");
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] island props were not valid JSON", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function whenReady(element: Element, strategy: string): Promise<void> {
|
||||
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
|
||||
return new Promise((resolve) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
});
|
||||
}
|
||||
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
|
||||
return new Promise((resolve) => requestIdleCallback(() => resolve()));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async function mountOne(element: Element, options: MountOptions): Promise<void> {
|
||||
if (roots.has(element)) return;
|
||||
|
||||
const name = element.getAttribute("data-wrn-island");
|
||||
if (!name) return;
|
||||
|
||||
const strategy = element.getAttribute("data-wrn-island-strategy") ?? "only";
|
||||
await whenReady(element, strategy);
|
||||
|
||||
// Re-check: an await point means a concurrent mount may have claimed this
|
||||
// element while the strategy was resolving.
|
||||
if (roots.has(element)) return;
|
||||
|
||||
let Component: ComponentType<any>;
|
||||
try {
|
||||
Component = (await options.loader(name)).default;
|
||||
} catch (error) {
|
||||
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (roots.has(element)) return;
|
||||
|
||||
const root = createRoot(element);
|
||||
roots.set(element, root);
|
||||
root.render(
|
||||
createElement(
|
||||
IslandErrorBoundary,
|
||||
{ name, development: options.development ?? false },
|
||||
createElement(Component, readProps(element)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Mounts every island marker under `root`. No-op when the page has none. */
|
||||
export async function mountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
||||
const markers = Array.from(root.querySelectorAll("[data-wrn-island]"));
|
||||
if (markers.length === 0) return;
|
||||
await Promise.all(markers.map((element) => mountOne(element, options)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes island roots under `root`. Must run on client-side navigation or
|
||||
* React roots, detached DOM, and store subscriptions leak on every route change.
|
||||
*/
|
||||
export function unmountIslands(root: ParentNode): void {
|
||||
for (const [element, reactRoot] of [...roots]) {
|
||||
if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
reactRoot.unmount();
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] island failed to unmount cleanly", error);
|
||||
}
|
||||
roots.delete(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only: dispose and re-create island roots after a source change.
|
||||
*
|
||||
* Island state resets by design — Fast Refresh needs a Babel/SWC transform plus
|
||||
* a runtime and is out of scope. `unmountIslands` clears each element from the
|
||||
* root registry, so the following `mountIslands` is not short-circuited by the
|
||||
* already-mounted guard.
|
||||
*/
|
||||
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
||||
unmountIslands(root);
|
||||
await mountIslands(root, options);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The island bootstrap served at `/__wrnexus/islands.js`.
|
||||
*
|
||||
* Mirrors the `@wrnexus/csr` pattern: this file is only ever fetched when a
|
||||
* `data-wrn-island` marker is present, so island-free pages download nothing —
|
||||
* including React.
|
||||
*/
|
||||
export function getIslandRuntime(development = false): string {
|
||||
return `
|
||||
(function () {
|
||||
function loader(name) {
|
||||
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js");
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!document.querySelector("[data-wrn-island]")) return;
|
||||
import("/__wrnexus/island/runtime.js").then(function (runtime) {
|
||||
runtime.mountIslands(document, { loader: loader, development: ${development} });
|
||||
window.__wrnexusUnmountIslands = function (root) {
|
||||
runtime.unmountIslands(root || document);
|
||||
};
|
||||
window.__wrnexusRemountIslands = function (root) {
|
||||
return runtime.remountIslands(root || document, {
|
||||
loader: loader,
|
||||
development: ${development}
|
||||
});
|
||||
};
|
||||
}).catch(function (error) {
|
||||
console.error("[wrnexus] failed to load the island runtime", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface SnapshotSource<S extends object> {
|
||||
snapshot(): Readonly<S>;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface SnapshotCache<S extends object> {
|
||||
getSnapshot(): Readonly<S>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a store instance so repeated `getSnapshot()` calls return the same
|
||||
* reference until the store notifies a change. `useSyncExternalStore` throws
|
||||
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
||||
* `readonlySnapshot` does by design.
|
||||
*/
|
||||
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
|
||||
let cached: Readonly<S> | undefined;
|
||||
let dirty = true;
|
||||
|
||||
const unsubscribe = source.subscribe(() => {
|
||||
dirty = true;
|
||||
});
|
||||
|
||||
return {
|
||||
getSnapshot() {
|
||||
if (dirty || cached === undefined) {
|
||||
cached = source.snapshot();
|
||||
dirty = false;
|
||||
}
|
||||
return cached;
|
||||
},
|
||||
dispose() {
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes a selector over a cached snapshot. Without this, any mutation
|
||||
* re-renders every island bound to the store, because snapshots are whole-state.
|
||||
*/
|
||||
export function createSelectorCache<S extends object, R>(
|
||||
getSnapshot: () => Readonly<S>,
|
||||
selector: (state: Readonly<S>) => R,
|
||||
isEqual: (a: R, b: R) => boolean = Object.is,
|
||||
): () => R {
|
||||
let lastSnapshot: Readonly<S> | undefined;
|
||||
let lastResult: R;
|
||||
let initialized = false;
|
||||
|
||||
return () => {
|
||||
const snapshot = getSnapshot();
|
||||
if (!initialized || snapshot !== lastSnapshot) {
|
||||
const next = selector(snapshot);
|
||||
if (!initialized || !isEqual(next, lastResult)) lastResult = next;
|
||||
lastSnapshot = snapshot;
|
||||
initialized = true;
|
||||
}
|
||||
return lastResult;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useDebugValue, useMemo, useSyncExternalStore } from "react";
|
||||
import { createSelectorCache, createSnapshotCache, type SnapshotCache } from "./snapshot-cache.ts";
|
||||
|
||||
export interface IslandStore<S extends object> {
|
||||
snapshot(): Readonly<S>;
|
||||
subscribe(listener: () => void): () => void;
|
||||
actions: Record<string, (...args: any[]) => unknown>;
|
||||
}
|
||||
|
||||
export type StoreResolver = (name: string) => IslandStore<any> | undefined;
|
||||
|
||||
let resolver: StoreResolver | null = null;
|
||||
let knownNames: string[] = [];
|
||||
|
||||
/** Registers how island stores are looked up. Set by the island runtime at mount. */
|
||||
export function setStoreResolver(next: StoreResolver | null, names: string[] = []): void {
|
||||
resolver = next;
|
||||
knownNames = names;
|
||||
}
|
||||
|
||||
export interface BoundStore<S extends object> {
|
||||
store: IslandStore<S>;
|
||||
getSnapshot: () => Readonly<S>;
|
||||
cache: SnapshotCache<S>;
|
||||
}
|
||||
|
||||
function resolveStore<S extends object>(name: string): BoundStore<S> {
|
||||
if (!resolver) {
|
||||
throw new Error(
|
||||
`useWrnStore("${name}") was called before the island runtime registered any stores.`,
|
||||
);
|
||||
}
|
||||
const store = resolver(name) as IslandStore<S> | undefined;
|
||||
if (!store) {
|
||||
const available = knownNames.length > 0 ? knownNames.join(", ") : "(none registered)";
|
||||
throw new Error(`Unknown WRNexus store "${name}". Available stores: ${available}`);
|
||||
}
|
||||
const cache = createSnapshotCache<S>(store);
|
||||
return { store, cache, getSnapshot: cache.getSnapshot };
|
||||
}
|
||||
|
||||
/** Test seam — exercises resolution and caching without rendering React. */
|
||||
export function getStoreForTest<S extends object>(name: string): BoundStore<S> {
|
||||
return resolveStore<S>(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a WRNexus store from inside a React island.
|
||||
*
|
||||
* Writes must go through `useWrnActions` from an event handler or effect —
|
||||
* never during render, which would loop.
|
||||
*/
|
||||
export function useWrnStore<S extends object, R = Readonly<S>>(
|
||||
name: string,
|
||||
selector?: (state: Readonly<S>) => R,
|
||||
isEqual?: (a: R, b: R) => boolean,
|
||||
): R {
|
||||
const bound = useMemo(() => resolveStore<S>(name), [name]);
|
||||
const read = useMemo(
|
||||
() =>
|
||||
selector
|
||||
? createSelectorCache<S, R>(bound.getSnapshot, selector, isEqual)
|
||||
: (bound.getSnapshot as unknown as () => R),
|
||||
[bound, selector, isEqual],
|
||||
);
|
||||
const value = useSyncExternalStore(bound.store.subscribe, read, read);
|
||||
useDebugValue(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Returns the action map for a store, for writes from handlers and effects. */
|
||||
export function useWrnActions(name: string): Record<string, (...args: any[]) => unknown> {
|
||||
return useMemo(() => resolveStore(name).store.actions, [name]);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** @jsxImportSource react */
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { IslandErrorBoundary } from "../src/error-boundary.tsx";
|
||||
|
||||
// React error boundaries only engage during client rendering — the server
|
||||
// renderers rethrow. Islands are client-only, so this is also how they run.
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error("chart exploded");
|
||||
}
|
||||
|
||||
function mountHost() {
|
||||
const window = new Window();
|
||||
(globalThis as any).window = window;
|
||||
(globalThis as any).document = window.document;
|
||||
const container = window.document.createElement("div");
|
||||
window.document.body.appendChild(container);
|
||||
return { window, container: container as unknown as HTMLElement };
|
||||
}
|
||||
|
||||
const silenced: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const restore of silenced.splice(0)) restore();
|
||||
});
|
||||
|
||||
function silenceExpectedErrors() {
|
||||
const original = console.error;
|
||||
console.error = () => {};
|
||||
silenced.push(() => {
|
||||
console.error = original;
|
||||
});
|
||||
}
|
||||
|
||||
test("renders children when nothing throws", async () => {
|
||||
const { container } = mountHost();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<IslandErrorBoundary name="Chart" development={false}>
|
||||
<p>ok</p>
|
||||
</IslandErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe("<p>ok</p>");
|
||||
});
|
||||
|
||||
test("contains a thrown error and shows details in development", async () => {
|
||||
silenceExpectedErrors();
|
||||
const { container } = mountHost();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<IslandErrorBoundary name="Chart" development>
|
||||
<Boom />
|
||||
</IslandErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toContain("Chart");
|
||||
expect(container.innerHTML).toContain("chart exploded");
|
||||
});
|
||||
|
||||
test("renders nothing in production when an island throws", async () => {
|
||||
silenceExpectedErrors();
|
||||
const { container } = mountHost();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<IslandErrorBoundary name="Chart" development={false}>
|
||||
<Boom />
|
||||
</IslandErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
test("a crashed island does not remove sibling server markup", async () => {
|
||||
silenceExpectedErrors();
|
||||
const { window, container } = mountHost();
|
||||
const sibling = window.document.createElement("p");
|
||||
sibling.textContent = "server rendered";
|
||||
window.document.body.appendChild(sibling);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<IslandErrorBoundary name="Chart" development={false}>
|
||||
<Boom />
|
||||
</IslandErrorBoundary>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(window.document.body.textContent).toContain("server rendered");
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act, createElement } from "react";
|
||||
import { islandRootCount, mountIslands, remountIslands, unmountIslands } from "../src/island-runtime.ts";
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function host(window: Window): ParentNode {
|
||||
return window.document.body as unknown as ParentNode;
|
||||
}
|
||||
|
||||
function domWith(html: string) {
|
||||
const window = new Window();
|
||||
window.document.body.innerHTML = html;
|
||||
(globalThis as any).window = window;
|
||||
(globalThis as any).document = window.document;
|
||||
return window;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
const doc = (globalThis as any).document;
|
||||
if (!doc) return;
|
||||
act(() => {
|
||||
unmountIslands(doc);
|
||||
});
|
||||
});
|
||||
|
||||
const marker =
|
||||
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"` +
|
||||
` data-wrn-island-props='{}'></div>`;
|
||||
|
||||
test("remount replaces island output without leaking roots", async () => {
|
||||
const window = domWith(marker);
|
||||
|
||||
const first = async () => ({ default: () => createElement("span", null, "v1") });
|
||||
const second = async () => ({ default: () => createElement("span", null, "v2") });
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader: first });
|
||||
});
|
||||
expect(window.document.body.textContent).toContain("v1");
|
||||
expect(islandRootCount()).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await remountIslands(host(window), { loader: second });
|
||||
});
|
||||
expect(window.document.body.textContent).toContain("v2");
|
||||
expect(window.document.body.textContent).not.toContain("v1");
|
||||
expect(islandRootCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("repeated remounts stay at one root", async () => {
|
||||
const window = domWith(marker);
|
||||
const loader = async () => ({ default: () => createElement("span", null, "x") });
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await act(async () => {
|
||||
await remountIslands(host(window), { loader });
|
||||
});
|
||||
}
|
||||
|
||||
expect(islandRootCount()).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act, createElement } from "react";
|
||||
import { islandRootCount, mountIslands, unmountIslands } from "../src/island-runtime.ts";
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// happy-dom's element types do not structurally match lib.dom's ParentNode;
|
||||
// this cast is a test-environment concern, not a runtime one.
|
||||
function host(window: Window): ParentNode {
|
||||
return window.document.body as unknown as ParentNode;
|
||||
}
|
||||
|
||||
function domWith(html: string) {
|
||||
const window = new Window();
|
||||
window.document.body.innerHTML = html;
|
||||
(globalThis as any).window = window;
|
||||
(globalThis as any).document = window.document;
|
||||
return window;
|
||||
}
|
||||
|
||||
const loader = async () => ({
|
||||
default: (props: { title?: string }) => createElement("span", null, props.title ?? "none"),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const doc = (globalThis as any).document;
|
||||
if (!doc) return;
|
||||
act(() => {
|
||||
unmountIslands(doc);
|
||||
});
|
||||
});
|
||||
|
||||
function marker(props = "{}", strategy = "only") {
|
||||
return (
|
||||
`<div data-wrn-island="Chart" data-wrn-island-strategy="${strategy}"` +
|
||||
` data-wrn-island-props='${props}'></div>`
|
||||
);
|
||||
}
|
||||
|
||||
test("mounts an island and passes deserialized props", async () => {
|
||||
const window = domWith(marker('{"title":"Revenue"}'));
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
|
||||
expect(window.document.body.textContent).toContain("Revenue");
|
||||
expect(islandRootCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("unmounts roots and leaves no leaked roots behind", async () => {
|
||||
const window = domWith(marker('{"title":"A"}'));
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
expect(islandRootCount()).toBe(1);
|
||||
|
||||
act(() => {
|
||||
unmountIslands(host(window));
|
||||
});
|
||||
expect(islandRootCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("repeated mount/unmount cycles do not accumulate roots", async () => {
|
||||
const window = domWith(marker());
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
act(() => {
|
||||
unmountIslands(host(window));
|
||||
});
|
||||
}
|
||||
|
||||
expect(islandRootCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("does nothing when no island markers are present", async () => {
|
||||
const window = domWith(`<p>plain server html</p>`);
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
expect(islandRootCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("mounting twice does not create a second root for the same element", async () => {
|
||||
const window = domWith(marker());
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
|
||||
expect(islandRootCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("a failing bundle load leaves the placeholder and mounts no root", async () => {
|
||||
const window = domWith(marker());
|
||||
const original = console.error;
|
||||
console.error = () => {};
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), {
|
||||
loader: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
});
|
||||
});
|
||||
console.error = original;
|
||||
|
||||
expect(islandRootCount()).toBe(0);
|
||||
expect(window.document.querySelector("[data-wrn-island]")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("malformed props JSON falls back to empty props instead of throwing", async () => {
|
||||
const window = domWith(marker("not-json"));
|
||||
const original = console.error;
|
||||
console.error = () => {};
|
||||
|
||||
await act(async () => {
|
||||
await mountIslands(host(window), { loader });
|
||||
});
|
||||
console.error = original;
|
||||
|
||||
expect(window.document.body.textContent).toContain("none");
|
||||
expect(islandRootCount()).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { getIslandRuntime } from "../src/runtime-source.ts";
|
||||
|
||||
test("emits a runtime that bails out when no island markers exist", () => {
|
||||
const source = getIslandRuntime(false);
|
||||
expect(source).toContain("data-wrn-island");
|
||||
expect(source).toContain("/__wrnexus/island/");
|
||||
});
|
||||
|
||||
test("registers a navigation hook so islands unmount on route change", () => {
|
||||
expect(getIslandRuntime(false)).toContain("__wrnexusUnmountIslands");
|
||||
});
|
||||
|
||||
test("never references react-dom/server", () => {
|
||||
expect(getIslandRuntime(true)).not.toContain("react-dom/server");
|
||||
});
|
||||
|
||||
test("threads the development flag into the mount options", () => {
|
||||
expect(getIslandRuntime(true)).toContain("development: true");
|
||||
expect(getIslandRuntime(false)).toContain("development: false");
|
||||
});
|
||||
|
||||
test("encodes the island name before using it as a URL path segment", () => {
|
||||
expect(getIslandRuntime(false)).toContain("encodeURIComponent");
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createSelectorCache, createSnapshotCache } from "../src/snapshot-cache.ts";
|
||||
|
||||
function fakeSource(initial: { count: number }) {
|
||||
let state = { ...initial };
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
snapshot: () => Object.freeze({ ...state }),
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
mutate(next: { count: number }) {
|
||||
state = next;
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("returns a referentially identical snapshot until a mutation occurs", () => {
|
||||
const source = fakeSource({ count: 0 });
|
||||
const cache = createSnapshotCache(source);
|
||||
|
||||
const first = cache.getSnapshot();
|
||||
const second = cache.getSnapshot();
|
||||
expect(first).toBe(second);
|
||||
|
||||
source.mutate({ count: 1 });
|
||||
const third = cache.getSnapshot();
|
||||
expect(third).not.toBe(first);
|
||||
expect(third.count).toBe(1);
|
||||
});
|
||||
|
||||
test("dispose unsubscribes from the source", () => {
|
||||
const source = fakeSource({ count: 0 });
|
||||
const cache = createSnapshotCache(source);
|
||||
cache.getSnapshot();
|
||||
cache.dispose();
|
||||
|
||||
source.mutate({ count: 5 });
|
||||
expect(cache.getSnapshot().count).toBe(0);
|
||||
});
|
||||
|
||||
test("selector cache keeps a stable result when the selected value is unchanged", () => {
|
||||
const source = fakeSource({ count: 0 });
|
||||
const cache = createSnapshotCache(source);
|
||||
const select = createSelectorCache(cache.getSnapshot, (state) => state.count);
|
||||
|
||||
const first = select();
|
||||
expect(select()).toBe(first);
|
||||
|
||||
// A new snapshot object, but the selected value is unchanged.
|
||||
source.mutate({ count: 0 });
|
||||
expect(select()).toBe(first);
|
||||
|
||||
source.mutate({ count: 2 });
|
||||
expect(select()).toBe(2);
|
||||
});
|
||||
|
||||
test("an object-returning selector needs a custom equality function to stay stable", () => {
|
||||
const source = fakeSource({ count: 0 });
|
||||
const cache = createSnapshotCache(source);
|
||||
|
||||
// Default Object.is can never stabilize a selector that allocates: each call
|
||||
// produces a distinct reference. This is the classic useSyncExternalStore
|
||||
// infinite-render footgun, so island authors get an explicit escape hatch.
|
||||
const unstable = createSelectorCache(cache.getSnapshot, (state) => ({
|
||||
label: `n=${state.count}`,
|
||||
}));
|
||||
const firstUnstable = unstable();
|
||||
source.mutate({ count: 0 });
|
||||
expect(unstable()).not.toBe(firstUnstable);
|
||||
|
||||
const stable = createSelectorCache(
|
||||
cache.getSnapshot,
|
||||
(state) => ({ label: `n=${state.count}` }),
|
||||
(a, b) => a.label === b.label,
|
||||
);
|
||||
const firstStable = stable();
|
||||
source.mutate({ count: 0 });
|
||||
expect(stable()).toBe(firstStable);
|
||||
|
||||
source.mutate({ count: 2 });
|
||||
expect(stable()).not.toBe(firstStable);
|
||||
expect(stable().label).toBe("n=2");
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { getStoreForTest, setStoreResolver } from "../src/store-bridge.ts";
|
||||
|
||||
function fakeStore(initial: { count: number }) {
|
||||
let state = { ...initial };
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
snapshot: () => Object.freeze({ ...state }),
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
actions: {
|
||||
increment: () => {
|
||||
state = { count: state.count + 1 };
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("resolves a registered store and caches its snapshot", () => {
|
||||
const store = fakeStore({ count: 0 });
|
||||
setStoreResolver((name) => (name === "counter" ? store : undefined), ["counter"]);
|
||||
|
||||
const bound = getStoreForTest<{ count: number }>("counter");
|
||||
expect(bound.getSnapshot()).toBe(bound.getSnapshot());
|
||||
|
||||
bound.store.actions.increment!();
|
||||
expect(bound.getSnapshot().count).toBe(1);
|
||||
|
||||
setStoreResolver(null);
|
||||
});
|
||||
|
||||
test("throws a helpful error for an unknown store name", () => {
|
||||
setStoreResolver(
|
||||
(name) => (name === "counter" ? fakeStore({ count: 0 }) : undefined),
|
||||
["counter"],
|
||||
);
|
||||
|
||||
expect(() => getStoreForTest("typo")).toThrow(/Unknown WRNexus store "typo"/);
|
||||
expect(() => getStoreForTest("typo")).toThrow(/counter/);
|
||||
|
||||
setStoreResolver(null);
|
||||
});
|
||||
|
||||
test("throws when no resolver has been registered yet", () => {
|
||||
setStoreResolver(null);
|
||||
expect(() => getStoreForTest("counter")).toThrow(/before the island runtime registered/);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"]
|
||||
}
|
||||
@@ -34,13 +34,28 @@ function loadTypeScript() {
|
||||
|
||||
const ts = loadTypeScript();
|
||||
|
||||
// React island modules are not used by the editor: island-bundle.ts calls
|
||||
// Bun.build and island-codegen.ts imports @wrnexus/core, neither of which
|
||||
// exists in this Node-only bundle. They are unreachable from the editor entry,
|
||||
// so excluding them keeps Bun-only code out of the extension entirely.
|
||||
const EDITOR_EXCLUDED = ["island-bundle.ts", "island-codegen.ts"];
|
||||
function isEditorExcluded(path) {
|
||||
return EDITOR_EXCLUDED.some((name) => path.endsWith(name));
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) files.push(...walk(path));
|
||||
else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path);
|
||||
else if (
|
||||
stat.isFile() &&
|
||||
path.endsWith(".ts") &&
|
||||
!path.endsWith(".test.ts") &&
|
||||
!isEditorExcluded(path)
|
||||
)
|
||||
files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -69,7 +84,6 @@ for (const file of sourceFiles) {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
moduleResolution: ts.ModuleResolutionKind.Node10,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
sourceMap: false,
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"@wrnexus/core": ["./packages/core/src/index.ts"],
|
||||
"@wrnexus/core/jsx-runtime": ["./packages/core/src/jsx-runtime.ts"],
|
||||
"@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"],
|
||||
"@wrnexus/react": ["./packages/react/src/index.ts"],
|
||||
"@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"],
|
||||
"@wrnexus/reactive": ["./packages/reactive/src/index.ts"],
|
||||
"@wrnexus/graphql": ["./packages/graphql/src/index.ts"],
|
||||
"@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],
|
||||
|
||||
Reference in New Issue
Block a user