Compare commits

...
13 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 442a3106ed test(islands): guard zero-JS routes and single-React bundling
Two guards protect the core promise: a route with no islands emits no
assets at all, and a page with several islands keeps React in one shared
chunk.

buildIslands now writes a generated entry per island instead of passing
component sources directly. Two islands sharing a source deduped to a
single entrypoint, and output order is not guaranteed to match input
order, so island names could bind to the wrong bundle.

Island modules are excluded from the editor compiler bundle: it globs
packages/compiler/src, and island-bundle.ts calls Bun.build while
island-codegen.ts imports @wrnexus/core — neither belongs in a Node-only
VS Code artifact.

Integration assertions share one build. bun test interferes with
Bun.build's module reads after several build calls in one process, while
the same calls succeed repeatedly outside the runner; production is
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:50:26 +05:30
ClintchizandClaude Opus 5 3115277e9d chore(ui): refresh the stale UI visual contract baseline
Card, Carousel, Footer, Navbar, input, select, and textarea last changed
in d78707be, but the baseline was last regenerated several commits
earlier, so check:ui-visual already failed on main.

Unrelated to the React islands work; committed separately so the feature
changeset does not absorb it. Only source hashes changed — no UI source
was modified here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:44:27 +05:30
ClintchizandClaude Opus 5 02fdfa3aee feat(react): remount islands on hot module replacement
Disposes and re-creates 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 for v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:34:36 +05:30
ClintchizandClaude Opus 5 5f66c8129c docs(react-islands): drop the write-during-render guard
Implemented and removed. A render-phase flag cleared on a microtask is
still set when React runs effects, so islands writing from an effect —
the documented correct pattern — would throw. The flag also cannot be
set for an island's own re-renders, so real violations pass silently.

Detecting React's render phase reliably needs React internals, which is
not acceptable in a shipped framework. React already reports the real
hazard, and the getSnapshot caching requirement covers the loop case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:33:48 +05:30
ClintchizandClaude Opus 5 01a3b3b4e9 feat(compiler): classify island routes as static-interactive
A route mounting an island ships JavaScript, so reporting it as zero-JS
static would make the framework's performance accounting wrong.

Adds a separate needsIslandRuntime flag rather than reusing
needsClientRuntime: an island needs the island runtime, not WRNexus's
reactive runtime, and conflating them would ship the wrong bundle.

analyzeRuntimeRequirements takes island presence as an optional second
argument, so existing callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:30:19 +05:30
ClintchizandClaude Opus 5 a184f1a3be feat(islands): serve the island runtime in dev, prod, and static builds
Adds /__wrnexus/islands.js (the bootstrap) and the /__wrnexus/island/
prefix (mount runtime, island bundles, shared chunks) to all three
serving paths.

Dev reuses the browserArtifactPaths registry pattern from pipeline.ts.
Prod mirrors the clientModulesDir handler, including its filename
allowlist, so island names cannot escape the output directory.

The bootstrap is inert without a data-wrn-island marker, so island-free
pages still download nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:28:10 +05:30
ClintchizandClaude Opus 5 06df9d66ae feat(compiler): bundle islands with a shared React chunk
splitting:true keeps React in one shared chunk so a page with several
islands does not ship react-dom repeatedly.

Island .tsx is compiled against React's JSX runtime via a Bun onLoad
plugin. The repo's root tsconfig sets jsxImportSource to @wrnexus/core,
so islands would otherwise compile to the HTML-string renderer and never
mount. A @jsxImportSource pragma only affects the file carrying it, so
injecting one into the generated entry is not enough — the injection has
to happen per source file. App-authored islands stay plain .tsx.

The JSX test asserts built output rather than generated entry text,
because the entry-text assertion passed while the mechanism did not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:22:58 +05:30
ClintchizandClaude Opus 5 d269772a79 feat(react): add island mount strategies and navigation-safe unmount
Mounts markers with client:only/load/visible/idle, and disposes roots on
route change so React roots, detached DOM, and store subscriptions do
not leak across client-side navigation.

Bundle load failures and malformed props JSON degrade to a warning and
leave the server markup intact rather than taking down the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:20:05 +05:30
ClintchizandClaude Opus 5 df2ee036eb feat(react): add per-island error boundary
A crashed island renders its error in dev and nothing in prod, leaving
the surrounding server-rendered page intact.

Island .tsx sources carry an explicit @jsxImportSource react pragma: the
repo's root tsconfig points jsxImportSource at @wrnexus/core, so without
it island JSX compiles to WRNexus's string renderer instead of React
elements.

Tests render on the client via createRoot rather than a server renderer,
because React error boundaries do not engage during SSR — and islands
are client-only regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:16:42 +05:30
ClintchizandClaude Opus 5 b405025f37 feat(compiler): resolve .tsx imports and tag them as islands
.wrn keeps resolution priority so existing components are unaffected
when a .tsx file shares their name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:15:08 +05:30
ClintchizandClaude Opus 5 cd07f0f8e2 feat(compiler): add island marker codegen and props contract
Emits the data-wrn-island placeholder, parses client:* strategies, and
rejects non-serializable props at compile time via WRN-ISLAND-PROPS so
the serialization boundary fails where it is cheapest to fix.

Island names become URL path segments when the browser fetches the
island bundle, so they are validated with core's existing
isSafeIslandName rather than relying on escaping alone. This adds
@wrnexus/core to the compiler's dependencies; core has no dependencies
of its own, so no cycle is introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:13:56 +05:30
ClintchizandClaude Opus 5 ce67d0d1d2 feat(react): add useWrnStore bridge over useSyncExternalStore
Resolves WRNexus stores from inside islands, reading through the cached
snapshot so React sees a stable reference. Unknown store names throw
with the list of registered stores rather than failing opaquely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:12:33 +05:30
ClintchizandClaude Opus 5 468f63f378 feat(react): add referentially-stable snapshot and selector caches
useSyncExternalStore requires getSnapshot to return an identical
reference when unchanged; @wrnexus/store's readonlySnapshot returns a
fresh clone per call. The cache lives here rather than in the store
package so existing consumers are untouched.

createSelectorCache takes an optional equality function: the Object.is
default can never stabilize a selector that allocates, which is the
usual source of infinite re-renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:11:22 +05:30
40 changed files with 4206 additions and 2427 deletions
+198 -736
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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
+7 -7
View File
@@ -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"
+28 -6
View File
@@ -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 -1
View File
@@ -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);
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
View File
@@ -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"
+12
View File
@@ -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);
+2 -1
View File
@@ -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:*"
}
}
+22 -1
View File
@@ -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") &&
+10 -1
View File
@@ -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,
+141
View File
@@ -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 });
}
}
+82
View File
@@ -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("&lt;");
expect(html).toContain("&quot;");
});
@@ -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");
});
+6 -1
View File
@@ -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));
+20
View File
@@ -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);
+11
View 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);
+9
View File
@@ -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.
+25
View File
@@ -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"
}
}
+44
View File
@@ -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>
);
}
}
+9
View File
@@ -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";
+115
View File
@@ -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);
}
+40
View File
@@ -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();
}
})();
`;
}
+62
View File
@@ -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;
};
}
+74
View File
@@ -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]);
}
+104
View File
@@ -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");
});
+67
View File
@@ -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);
});
+130
View File
@@ -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");
});
+50
View File
@@ -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/);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"]
}
+16 -2
View File
@@ -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,
+2
View File
@@ -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"],