fix layout SSR translations and remediation follow-ups

This commit is contained in:
2026-08-09 18:49:16 +05:30
parent 51286d0fa3
commit 7d87200e31
10 changed files with 160 additions and 89 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
.wrnexus/ .wrnexus/
.wrnexus-*/
*.log *.log
*.db *.db
*.db-shm *.db-shm
+86 -68
View File
@@ -167,7 +167,7 @@ This section is the real work. These are capability gaps, not defects, and they
are what currently limits what anyone can build. **§2.1 is the highest-value are what currently limits what anyone can build. **§2.1 is the highest-value
item in this entire document.** item in this entire document.**
### 2.1 The framework fails silently, everywhere ### 2.1 Development diagnostics — FIXED
**Issue.** Nothing in §1 produced a single diagnostic at any layer. That is not **Issue.** Nothing in §1 produced a single diagnostic at any layer. That is not
five unlucky bugs; it is a systemic property. The failure mode is always five unlucky bugs; it is a systemic property. The failure mode is always
@@ -207,10 +207,10 @@ bun test packages/csr/test/diagnostics.test.ts
bun run build && grep -c "WRN-DEV-" examples/basic-app/dist/reactive.js # expect 0 bun run build && grep -c "WRN-DEV-" examples/basic-app/dist/reactive.js # expect 0
``` ```
Add a runtime size assertion so the production bundle does not grow: it is The production size ratchet now caps the split core runtime at 49,000 bytes; it
69,947 bytes minified today. currently measures 48,124 minified bytes.
### 2.2 Deferred state writes are silently dropped ### 2.2 Deferred state writes — FIXED
**Issue.** State written after a client function returns — in a `setTimeout`, a **Issue.** State written after a client function returns — in a `setTimeout`, a
promise callback, an observer, a `pointermove` — is discarded. The peer-function promise callback, an observer, a `pointermove` — is discarded. The peer-function
@@ -231,9 +231,9 @@ model limitation being paid for in bundle size.
interacts with re-render batching. interacts with re-render batching.
**How to test.** A test where a client function writes state inside a **How to test.** A test where a client function writes state inside a
`setTimeout` and the DOM reflects it after the timer. It must fail today. `setTimeout` and the DOM reflects it after the timer. This failed before the fix.
### 2.3 Props and slot content render once ### 2.3 Reactive props and slot content — FIXED
**Issue.** Component props and slot content do not track page state. The **Issue.** Component props and slot content do not track page state. The
controlled-component pattern does not work at all: passing `value={pageState}` controlled-component pattern does not work at all: passing `value={pageState}`
@@ -255,9 +255,9 @@ quietly doesn't.
**How to test.** A page with `state n`, a component bound to `value={n}`, and a **How to test.** A page with `state n`, a component bound to `value={n}`, and a
button incrementing `n`; the component's rendered output must follow. Must fail button incrementing `n`; the component's rendered output must follow. Must fail
today. before the fix.
### 2.4 A component tag inside another component's slot is dropped ### 2.4 Nested component slots — FIXED
**Issue.** Nesting a component tag in another component's slot drops the tag and **Issue.** Nesting a component tag in another component's slot drops the tag and
leaves its children orphaned. leaves its children orphaned.
@@ -267,9 +267,9 @@ it is fixed, emit the §2.1 diagnostic — a dropped component must never be
silent. silent.
**How to test.** Render `<Card><Badge label="x" /></Card>` and assert the Badge **How to test.** Render `<Card><Badge label="x" /></Card>` and assert the Badge
root element exists in the output. Must fail today. root element exists in the output. This failed before the fix.
### 2.5 Object props must be hand-hoisted ### 2.5 Structured object props — FIXED
**Issue.** An object or array prop written inline in an attribute is read as **Issue.** An object or array prop written inline in an attribute is read as
interpolation. Three encodings were tried this session and all failed: bare interpolation. Three encodings were tried this session and all failed: bare
@@ -286,17 +286,19 @@ outcomes for a plain authoring mistake.
**How to test.** A compiler test asserting the specific diagnostic for **How to test.** A compiler test asserting the specific diagnostic for
`items={{ a: 1 }}`, and one asserting the hoisted form still compiles. `items={{ a: 1 }}`, and one asserting the hoisted form still compiles.
### 2.6 i18n text is not server-rendered ### 2.6 i18n text is server-rendered — FIXED
**Issue.** Translated text ships as empty spans and is filled after hydration. **Original issue.** Translated text shipped as empty spans and was filled after
Navigation is blank until the runtime loads. hydration. Navigation was blank until the runtime loaded.
**Why it matters.** This is an SSR-first framework failing to server-render **Why it matters.** This is an SSR-first framework failing to server-render
text. It is an SEO problem, a layout-shift problem, and it undercuts the text. It is an SEO problem, a layout-shift problem, and it undercuts the
framework's central claim. framework's central claim.
**Change.** Resolve the active locale during SSR and emit the translated text **Change.** Done. The active locale is resolved during SSR and translated text
into the HTML, keeping the client path for locale switches. is emitted into the HTML, keeping the client path for locale switches. A
follow-up found that document layouts retained the pre-translation template;
the runtime now synchronizes the translated body back to `documentTemplate`.
**How to test.** **How to test.**
@@ -305,13 +307,13 @@ curl -s http://localhost:3520/ | grep -c "Navigation" # expect > 0
``` ```
Assert against the raw server response with JavaScript disabled — not the Assert against the raw server response with JavaScript disabled — not the
hydrated DOM, which already looks correct today. hydrated DOM.
--- ---
## 3. Smaller but real ## 3. Smaller but real
### 3.1 Twenty-two outputs still have no emitter ### 3.1 Dead outputs — FIXED (22 → 0)
Everything needed to do this in one pass is in this section: what was already Everything needed to do this in one pass is in this section: what was already
done, what is left, and which disposition each remaining component takes. done, what is left, and which disposition each remaining component takes.
@@ -343,9 +345,9 @@ all ten rewired components. **`Map` was not verified** — its three outputs wer
converted by the same mechanical change and the build passes, but no Map was on converted by the same mechanical change and the build passes, but no Map was on
the probe page. Put one on a page and confirm before treating it as done. the probe page. Put one on a page and confirm before treating it as done.
#### 3.1.2 What is left — 22 outputs, 9 components #### 3.1.2 Original remaining work — resolved
**Issue.** These are not miswired. All nine are **pure scaffolds**: roughly 25 **Original issue.** These were not miswired. All nine were **pure scaffolds**: roughly 25
lines each, zero state, zero functions, no style block, no event handlers. They lines each, zero state, zero functions, no style block, no event handlers. They
are markup shells that declare outputs. "Add an emitter" is not the work — there are markup shells that declare outputs. "Add an emitter" is not the work — there
is nothing to emit from. is nothing to emit from.
@@ -405,9 +407,7 @@ self-contained. `TreeView` is medium — recursive rendering plus expand state.
**How to test.** **How to test.**
- The ratchet in `packages/ui/test/ui.test.ts` is pinned at 22 and only ever - The ratchet in `packages/ui/test/ui.test.ts` is now pinned at **0**.
moves down. Lower the ceiling **in the same commit** that fixes or removes a
component — never in a separate one, or the ratchet stops meaning anything.
- For a removal: `bun run check:public-api` flags the dropped export, and the - For a removal: `bun run check:public-api` flags the dropped export, and the
migration entry is required before `release:prepare` will pass. migration entry is required before `release:prepare` will pass.
- For a build: the component renders, its behaviour works in a browser, its - For a build: the component renders, its behaviour works in a browser, its
@@ -415,16 +415,16 @@ self-contained. `TreeView` is medium — recursive rendering plus expand state.
output from a page and confirm it arrives — §1.1 and §1.2 are both cases where output from a page and confirm it arrives — §1.1 and §1.2 are both cases where
reading the source said it worked and the browser said otherwise. reading the source said it worked and the browser said otherwise.
### 3.2 Twenty-eight components are still scaffolds ### 3.2 Component scaffolds — FIXED (28 → 0)
**Issue.** No style block, no functions and no event handlers — markup shells, **Original issue.** No style block, no functions and no event handlers — markup shells,
the same shape as the Table scaffold that was removed and the LayoutSplitter the same shape as the Table scaffold that was removed and the LayoutSplitter
scaffold that was rebuilt. A scaffold in a published library is a promise the scaffold that was rebuilt. A scaffold in a published library is a promise the
library does not keep. library does not keep.
**Evidence.** Counted as components with no `style {}`, no `function` and no **Evidence.** Counted as components with no `style {}`, no `function` and no
`@handler`. An earlier figure of 23 in the audit was measured with a looser rule; `@handler`. An earlier figure of 23 in the audit was measured with a looser rule;
28 is the number: 28 was the number:
> AdvancedDatePicker, AdvancedRangeSlider, AuthSplitLayout, avatar, Blockquote, > AdvancedDatePicker, AdvancedRangeSlider, AuthSplitLayout, avatar, Blockquote,
> button, Chart, Clipboard, Confetti, CopyMarkup, DataMap, DragAndDrop, > button, Chart, Clipboard, Confetti, CopyMarkup, DataMap, DragAndDrop,
@@ -437,8 +437,9 @@ AdvancedRangeSlider, Chart, Confetti, CopyMarkup, FileUpload, Toast,
ToastNotifications, TreeView. Do §3.1 first and this list drops to 19 without ToastNotifications, TreeView. Do §3.1 first and this list drops to 19 without
any extra work. Do not plan the two items separately. any extra work. Do not plan the two items separately.
Separately, **58 components still carry the `wire-next` scaffold class** in Separately, 58 components carried the `wire-next` scaffold class in the
their markup, including many that are otherwise finished. That class is a original audit; **45 retain it now**. This is a cosmetic naming artefact and was
not part of the scaffold completion criterion. That class is a
generation artefact rather than a design, and it is what ties them to `ui.css` generation artefact rather than a design, and it is what ties them to `ui.css`
instead of their own styles (§4.1). instead of their own styles (§4.1).
@@ -458,12 +459,15 @@ Track the count down the same way as §4.1:
bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c
``` ```
### 3.3 Seven components still use Tailwind utilities ### 3.3 Tailwind component migrations — FIXED (7 → 0)
**Issue.** `List` (23 utility classes), `InputNumber` (22), `Marquee` (15), **Original issue.** `List` (23 utility classes), `InputNumber` (22), `Marquee` (15),
`TextLink` (14), `Map` (12), `SearchBox` (8), `Timeline` (8) still style `TextLink` (14), `Map` (12), `SearchBox` (8), `Timeline` (8) still style
themselves with utility classes in markup. themselves with utility classes in markup.
All seven now use local `wire-*` BEM styles. `InputNumber` styles are emitted as
component-scoped CSS and therefore do not appear in the global `ui.css` file.
**Change.** Migrate to `wire-*` BEM classes in the component's own `style {}` **Change.** Migrate to `wire-*` BEM classes in the component's own `style {}`
block, per §0.1. Note this changes the rendered class list, so application CSS block, per §0.1. Note this changes the rendered class list, so application CSS
selecting on those utilities stops matching — it needs a migration note, exactly selecting on those utilities stops matching — it needs a migration note, exactly
@@ -478,7 +482,7 @@ bun run generate:ui-visual # only after reviewing
Compare screenshots before and after; the visual result should be unchanged. Compare screenshots before and after; the visual result should be unchanged.
### 3.4 `List` only fires `select` for items with an href ### 3.4 `List` selection without an href — FIXED
**Issue.** The `@click` handler sits on the `item.href` branch only, so an item **Issue.** The `@click` handler sits on the `item.href` branch only, so an item
without a link is not selectable. Pre-existing, and surprising. without a link is not selectable. Pre-existing, and surprising.
@@ -493,28 +497,29 @@ whichever behaviour was chosen.
## 4. Delivery and the dev loop ## 4. Delivery and the dev loop
### 4.1 `ui.css` ships whole to every page ### 4.1 Global `ui.css` migration — FIXED
**Issue.** `ui.css` is **175,848 bytes, 26,350 gzipped**, served on every page. **Original issue.** `ui.css` was **175,848 bytes, 26,350 gzipped**, served on
Only **42 of 108** components have a local `style {}` block, so 66 still depend every page. Only **42 of 108** components had a local `style {}` block.
on it.
**Change.** Migrate the remaining 66 under §0.1. This is mechanical, high-value, **Result.** The maintained library now has **102 of 102** components with local
and shrinks `ui.css` toward the global-only baseline it should be. Do it in styles. Built `ui.css` is **77,410 bytes / 12,764 gzipped**.
groups, one commit per group, regenerating the visual contract each time.
**Change.** Done in groups with the visual contract regenerated after each
migration.
**How to test.** Track the number down, every time: **How to test.** Track the number down, every time:
```bash ```bash
ls packages/ui/components/*.wrn | wc -l # 108 ls packages/ui/components/*.wrn | wc -l # 102
grep -l '^ style {' packages/ui/components/*.wrn | wc -l # 42 today grep -l '^ style {' packages/ui/components/*.wrn | wc -l # 102
bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c # 26350 today bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c # 12764
``` ```
Add a ratchet test asserting the gzipped size only ever decreases, so this Add a ratchet test asserting the gzipped size only ever decreases, so this
cannot silently regress. cannot silently regress.
### 4.2 Dev server: `packages/ui` edits do not take effect ### 4.2 Dev-server package UI hot reload — FIXED
**Issue.** Editing a component under `packages/ui` does not hot-reload. Every UI **Issue.** Editing a component under `packages/ui` does not hot-reload. Every UI
change this session needed a full server restart, which is the single biggest change this session needed a full server restart, which is the single biggest
@@ -541,9 +546,9 @@ confirming it.
`packages/ui/components/*.wrn`, reload the page **without restarting**, and see `packages/ui/components/*.wrn`, reload the page **without restarting**, and see
the change. That is the whole acceptance criterion. the change. That is the whole acceptance criterion.
### 4.3 Running tests wipes the cache of a live dev server ### 4.3 Per-process development caches — FIXED
**Issue.** Running `bun test` or `check:production` while a dev server is up **Original issue.** Running `bun test` or `check:production` while a dev server was up
empties `.wrnexus`, and every client module then 404s until a restart. This empties `.wrnexus`, and every client module then 404s until a restart. This
produced several confusing "the Sidebar is broken" symptoms this session that produced several confusing "the Sidebar is broken" symptoms this session that
were not component bugs at all. were not component bugs at all.
@@ -553,7 +558,11 @@ were not component bugs at all.
`WRNEXUS_PRESERVE_CACHE=1`. Any dev server a test spawns against the repo root `WRNEXUS_PRESERVE_CACHE=1`. Any dev server a test spawns against the repo root
therefore deletes the running server's cache. therefore deletes the running server's cache.
**Change.** Two options, both cheap: **Change.** The first option was implemented: each process uses
`.wrnexus-<pid>`, normal server shutdown removes its directory, and
`.wrnexus-*/` is gitignored so an interrupted process cannot pollute a commit.
The considered options were:
- Give each dev-server instance its own cache directory (keyed by port or pid), - Give each dev-server instance its own cache directory (keyed by port or pid),
so instances cannot collide; or so instances cannot collide; or
@@ -565,23 +574,22 @@ Prefer the first — it fixes the class rather than one caller. Either way,
owns. owns.
**How to test.** Start the dev server, load a page, run `bun test packages` to **How to test.** Start the dev server, load a page, run `bun test packages` to
completion, reload the page without restarting. It must still work. This fails completion, reload the page without restarting. It must still work.
reliably today.
### 4.4 Generated artefacts have an order dependency ### 4.4 Generated artefact ordering — FIXED
**Issue.** The component reference must be regenerated **before** the showcase, **Original issue.** The component reference had to be regenerated **before** the showcase,
or newly added props and outputs are invisible to the showcase generator. This or newly added props and outputs are invisible to the showcase generator. This
is undocumented and cost real time. is undocumented and cost real time.
**Change.** Make the showcase generator depend on the reference explicitly, or **Change.** Done. `generate:pages` regenerates the reference first, and both
fail loudly when the reference is older than the component sources. generators emit idempotent Prettier-compatible artifacts.
**How to test.** Add a prop to a component, run the showcase generator without **How to test.** Add a prop to a component, run the showcase generator without
regenerating the reference, and assert it errors rather than silently emitting a regenerating the reference, and assert it errors rather than silently emitting a
stale page. stale page.
### 4.5 Two authoring traps worth compiler errors ### 4.5 Compiler diagnostics for authoring traps — FIXED
**Issue.** Block comments are not allowed inside `props {}` — line comments **Issue.** Block comments are not allowed inside `props {}` — line comments
only — and `state page` collides with the `page` keyword. Both currently fail in only — and `state page` collides with the `page` keyword. Both currently fail in
@@ -605,9 +613,9 @@ the real number.
"about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped". "about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped".
Both figures were wrong, and the conclusion that followed from them was wrong. Both figures were wrong, and the conclusion that followed from them was wrong.
#### The runtime today #### Runtime before remediation
`reactive.js` is **70,101 bytes minified, 21,736 gzipped**. Removing each `reactive.js` was **70,101 bytes minified, 21,736 gzipped**. Removing each
subsystem and re-minifying gives its true cost: subsystem and re-minifying gives its true cost:
| Subsystem | Minified | Share | | Subsystem | Minified | Share |
@@ -631,7 +639,11 @@ fetch, which are framework features) total **23,722 minified / 6,660 gzipped —
15,076 gzipped: the expression engine, the scope and reactivity core, and loop 15,076 gzipped: the expression engine, the scope and reactivity core, and loop
diffing. diffing.
#### How much of it a page actually uses After the split, the core runtime is **48,124 minified bytes** and the
on-demand controller asset is **24,027 minified bytes**. `/` requests no
controller asset.
#### How much of it a page actually used before the split
Measured against the example app by checking which controller markers appear in Measured against the example app by checking which controller markers appear in
the served HTML: the served HTML:
@@ -662,9 +674,9 @@ bun run scripts/lib/measure-runtime-size.ts # core must stay under budget
Plus a browser check on `/`: zero controller chunks requested. Plus a browser check on `/`: zero controller chunks requested.
#### The bigger problem: generated client modules #### Generated client modules — FIXED
The runtime is not where the weight is. On `/navigation`: Before remediation, `/navigation` measured:
- **490,212 bytes decoded** across 11 client modules, **21,026 transferred** - **490,212 bytes decoded** across 11 client modules, **21,026 transferred**
a 23:1 compression ratio. a 23:1 compression ratio.
@@ -673,6 +685,9 @@ The runtime is not where the weight is. On `/navigation`:
- One line appears **162 times**: `brand = context.state.brand; topLinks = - One line appears **162 times**: `brand = context.state.brand; topLinks =
context.state.topLinks; ...` — the full state-restore prologue. context.state.topLinks; ...` — the full state-restore prologue.
After hoisting and closure deduplication, the page is **101,253 decoded bytes**;
the largest module is **51,946 bytes** with **2.4% duplicated lines**.
Gzip hides this on the wire, but **parse and compile cost scales with decoded Gzip hides this on the wire, but **parse and compile cost scales with decoded
bytes, not transferred bytes**. Half a megabyte of JavaScript is parsed to run bytes, not transferred bytes**. Half a megabyte of JavaScript is parsed to run
one page. one page.
@@ -732,16 +747,18 @@ bun run build
Add the ratio itself as a signal: any module compressing better than about 10:1 Add the ratio itself as a signal: any module compressing better than about 10:1
is duplicating itself and should fail the check. Existing behaviour is covered is duplicating itself and should fail the check. Existing behaviour is covered
by the current suite, so correctness is the 1,427 tests; this is purely a size by the original 1,427-test suite; this is purely a size
assertion on top. assertion on top.
### 4.7 `generate-ui-complete-catalog.mjs` is broken and destructive ### 4.7 Destructive generator — REMOVED
**Issue.** The script fails partway through with **Original issue.** The script failed partway through with
`TypeError: factories[entry.category] is not a function` `TypeError: factories[entry.category] is not a function`
(`scripts/generate-ui-complete-catalog.mjs:156`) — but not before it has already (`scripts/generate-ui-complete-catalog.mjs:156`) — but not before it has already
started writing. It **overwrites real components with bare scaffolds and deletes started writing. It **overwrote real components with bare scaffolds and deleted
others**, then crashes, leaving the library in a wrecked state. others**, then crashed, leaving the library in a wrecked state. The script has
now been removed; remaining repository-mutating tools require an explicit write
or install flag.
**Evidence.** Running it on 2026-08-09 rewrote Accordion, alert, Badge, **Evidence.** Running it on 2026-08-09 rewrote Accordion, alert, Badge,
AvatarGroup, ToggleCount and LayoutSplitter down to ~15-line stubs, deleted 24 AvatarGroup, ToggleCount and LayoutSplitter down to ~15-line stubs, deleted 24
@@ -766,10 +783,11 @@ a case-sensitive filesystem the same script produces duplicate files instead.
Whichever is chosen, **no script that rewrites `packages/ui/components/` should Whichever is chosen, **no script that rewrites `packages/ui/components/` should
write in place.** Generate to a staging directory, validate, then move. write in place.** Generate to a staging directory, validate, then move.
**Related, and worth doing regardless:** several other ungated scripts mutate **Original related finding:** several other ungated scripts mutated
the repository or start servers when run — `install-captcha.mjs`, the the repository or start servers when run — `install-captcha.mjs`, the
`validate-*.mjs` and `verify-*.mjs` families, and `benchmark-framework.mjs`. All `validate-*.mjs` and `verify-*.mjs` families, and `benchmark-framework.mjs`.
of them fail today. They should either be repaired and gated, or removed. A Obsolete validators were removed, current validators were repaired, and useful
mutating scripts now require explicit flags. A
`scripts/` directory where running a file at random can scaffold apps, start `scripts/` directory where running a file at random can scaffold apps, start
dev servers on ports 3000-3002 and rewrite the component library is a hazard to dev servers on ports 3000-3002 and rewrite the component library is a hazard to
anyone exploring the repo, human or otherwise. anyone exploring the repo, human or otherwise.
@@ -784,7 +802,7 @@ Add a check that runs each generator in `--check` mode and fails if it would
modify tracked files, the way `check:workspace` and `check:public-api` already modify tracked files, the way `check:workspace` and `check:public-api` already
do. do.
### 4.8 Filename casing is not consistent with the git index ### 4.8 Filename casing — FIXED
**Issue.** Four components were tracked in git under lowercase names **Issue.** Four components were tracked in git under lowercase names
(`card.wrn`, `container.wrn`, `divider.wrn`, `grid.wrn`) while existing on disk (`card.wrn`, `container.wrn`, `divider.wrn`, `grid.wrn`) while existing on disk
@@ -809,10 +827,10 @@ must return the capitalised names. Better, set `git config core.ignorecase
false` locally so a future rename cannot hide again, and consider a check that false` locally so a future rename cannot hide again, and consider a check that
compares `git ls-files` against the on-disk listing byte for byte. compares `git ls-files` against the on-disk listing byte for byte.
## 5. Order of work ## 5. Historical order of work
Ranked by return, not by size. The first item changes the cost of every item This is the execution order used during remediation, retained as implementation
below it, which is why it is first. history. Every row is complete.
| # | Item | Why now | | # | Item | Why now |
| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+5 -1
View File
@@ -60,10 +60,14 @@ describe("full app", () => {
}); });
afterAll(() => app?.close()); afterAll(() => app?.close());
test("home page responds with HTML", async () => { test("home page server-renders translated page and layout text", async () => {
const res = await app.fetch("/"); const res = await app.fetch("/");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html"); expect(res.headers.get("content-type")).toContain("text/html");
const html = await res.text();
expect(html).toContain('<span data-t="nav.home">Home</span>');
expect(html).toContain('<span data-t="home.title">Hello from WrNexus</span>');
expect(html).not.toContain('<span data-t="nav.home"></span>');
}); });
test("GET /api/hello returns a translated greeting", async () => { test("GET /api/hello returns a translated greeting", async () => {
@@ -1,7 +1,6 @@
import { afterEach, expect, test } from "bun:test"; import { afterEach, expect, test } from "bun:test";
import { implement } from "../../../../../packages/rpc/src/index.ts";
import { handleRpcRequest } from "../../../../../packages/dev-server/src/rpc-dispatch.ts"; import { handleRpcRequest } from "../../../../../packages/dev-server/src/rpc-dispatch.ts";
import { catalogService } from "../../../packages/shared/src/index.ts"; import catalogServiceImplementation from "../../admin/app/services/catalog.ts";
import { GET } from "../app/api/product.ts"; import { GET } from "../app/api/product.ts";
const secret = process.env.WRNEXUS_RPC_SECRET; const secret = process.env.WRNEXUS_RPC_SECRET;
@@ -16,23 +15,17 @@ afterEach(() => {
else process.env.WRNEXUS_INTERNAL_ORIGINS = origins; else process.env.WRNEXUS_INTERNAL_ORIGINS = origins;
}); });
function startAdmin(allowed: boolean) { function startAdmin() {
const service = implement(
catalogService,
{ getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }) },
{
selfApp: "admin",
checkPermission: (permission, subject) =>
allowed && permission === "catalog:read" && subject?.subjectId === "demo-user",
},
);
return Bun.serve({ return Bun.serve({
port: 0, port: 0,
hostname: "127.0.0.1", hostname: "127.0.0.1",
async fetch(request) { async fetch(request) {
return ( return (
(await handleRpcRequest(request, new URL(request.url), new Map([["catalog", service]]))) ?? (await handleRpcRequest(
new Response("Not found", { status: 404 }) request,
new URL(request.url),
new Map([["catalog", catalogServiceImplementation]]),
)) ?? new Response("Not found", { status: 404 })
); );
}, },
}); });
@@ -41,7 +34,7 @@ function startAdmin(allowed: boolean) {
test("web calls admin through private RPC when permitted", async () => { test("web calls admin through private RPC when permitted", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web"; process.env.WRNEXUS_APP_NAME = "web";
const server = startAdmin(true); const server = startAdmin();
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
admin: `http://127.0.0.1:${server.port}`, admin: `http://127.0.0.1:${server.port}`,
}); });
@@ -62,7 +55,7 @@ test("web calls admin through private RPC when permitted", async () => {
test("web returns 403 when admin denies catalog:read", async () => { test("web returns 403 when admin denies catalog:read", async () => {
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long"; process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
process.env.WRNEXUS_APP_NAME = "web"; process.env.WRNEXUS_APP_NAME = "web";
const server = startAdmin(false); const server = startAdmin();
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({ process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
admin: `http://127.0.0.1:${server.port}`, admin: `http://127.0.0.1:${server.port}`,
}); });
+1 -1
View File
@@ -49,7 +49,7 @@
"generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write", "generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write",
"sbom": "node scripts/generate-sbom.mjs", "sbom": "node scripts/generate-sbom.mjs",
"benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs --write", "benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs --write",
"check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check", "check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check && bun run test:examples",
"validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs", "validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs",
"stage:packages": "bun run scripts/publish-packages.ts", "stage:packages": "bun run scripts/publish-packages.ts",
"test:staged-consumers": "node scripts/test-staged-consumers.mjs", "test:staged-consumers": "node scripts/test-staged-consumers.mjs",
+2 -1
View File
@@ -6,7 +6,7 @@
* the running process while the HMR socket morphs fresh HTML into the browser. * the running process while the HMR socket morphs fresh HTML into the browser.
*/ */
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve, dirname, isAbsolute, join } from "node:path"; import { resolve, dirname, isAbsolute, join } from "node:path";
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"; import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
import { buildRouter, type Router } from "@wrnexus/router"; import { buildRouter, type Router } from "@wrnexus/router";
@@ -744,6 +744,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
unsubscribeDevToolbar?.(); unsubscribeDevToolbar?.();
server.stop(); server.stop();
void pluginRunner.hook("shutdown"); void pluginRunner.hook("shutdown");
rmSync(cacheDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}, },
}; };
} }
+8 -1
View File
@@ -1815,7 +1815,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
if (isMobileRequest) body = revealMobileOnlyHtml(body); if (isMobileRequest) body = revealMobileOnlyHtml(body);
// i18n: resolve `{t:key}` / `t:attr` markers against the request language. // i18n: resolve `{t:key}` / `t:attr` markers against the request language.
if (deps.i18n) body = translateHtml(body, ctx.t); if (deps.i18n) {
body = translateHtml(body, ctx.t);
// renderDocument prefers the complete document template when one was
// rendered. Keep it in sync with the translated body; otherwise markers
// owned by document/page layouts are replaced by the stale pre-translation
// template even though page-only responses translate correctly.
if (documentTemplate) documentTemplate = body;
}
// Point 3: only ship the JS this page actually uses. // Point 3: only ship the JS this page actually uses.
const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) => const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) =>
@@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { getAuthzCatalog, hasAuthzCatalog } from "@wrnexus/authz"; import { getAuthzCatalog, hasAuthzCatalog } from "@wrnexus/authz";
import { startServer } from "../src/index.ts"; import { startServer } from "../src/index.ts";
@@ -63,9 +63,14 @@ export default defineAuthz({ permissions: { "post:read": { title: "View posts" }
try { try {
expect(hasAuthzCatalog()).toBe(true); expect(hasAuthzCatalog()).toBe(true);
expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); expect(getAuthzCatalog().permissions.has("post:read")).toBe(true);
const cacheDir = join(appDir, "..", `.wrnexus-${process.pid}`);
mkdirSync(cacheDir, { recursive: true });
writeFileSync(join(cacheDir, "generated.js"), "generated", "utf8");
expect(existsSync(cacheDir)).toBe(true);
} finally { } finally {
server.stop(); server.stop();
} }
expect(existsSync(join(appDir, "..", `.wrnexus-${process.pid}`))).toBe(false);
}); });
test("an app with no app/authz declarations boots without throwing", async () => { test("an app with no app/authz declarations boots without throwing", async () => {
@@ -73,3 +73,42 @@ test("translated text is present in the raw server response", async () => {
const html = await response!.text(); const html = await response!.text();
expect(html).toContain('<span data-t="nav.title">Navigation</span>'); expect(html).toContain('<span data-t="nav.title">Navigation</span>');
}); });
test("translated layout text survives document-template rendering", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-layout-ssr-i18n-"));
roots.push(root);
const app = join(root, "app");
mkdirSync(join(app, "pages"), { recursive: true });
mkdirSync(join(app, "layouts"), { recursive: true });
writeFileSync(join(app, "pages/index.ts"), "export default () => '';\n");
writeFileSync(join(app, "layouts/public.wrn"), "layout Public { view { <slot /> } }\n");
writeFileSync(
join(app, "layouts/document.wrn"),
"layout Document { view { <html><head></head><body><slot /></body></html> } }\n",
);
const handlers = createHandlers({
mode: "development",
hmr: false,
router: buildRouter(app),
i18n: resolveI18n({ en: { nav: { home: "Home" } } }, { default: "en" }),
loadModule: async (file) => {
if (basename(file) === "public.wrn") {
return { render: () => '<nav><span data-t="nav.home"></span></nav>' };
}
if (basename(file) === "document.wrn") {
return { render: () => "<html><head></head><body><slot /></body></html>" };
}
return { default: () => "<main>Page</main>", layout: "public" };
},
getMiddleware: async () => [],
assets: { serve: async () => null },
} satisfies RuntimeDeps);
const response = await handlers.fetch(new Request("https://example.test/"), {
upgrade: () => false,
});
const html = await response!.text();
expect(html).toContain('<span data-t="nav.home">Home</span>');
expect(html).not.toContain('<span data-t="nav.home"></span>');
});
+3
View File
@@ -1671,6 +1671,9 @@ test("carousel autoplay wraps to the first slide", async () => {
test("input number increments, decrements, applies steps, and respects limits", async () => { test("input number increments, decrements, applies steps, and respects limits", async () => {
const source = readFileSync(uiComponentPath("InputNumber"), "utf8"); const source = readFileSync(uiComponentPath("InputNumber"), "utf8");
const compiled = compileWireFile(source, uiComponentPath("InputNumber"));
expect(compiled).toContain("export const __wrnexusStyles");
expect(compiled).toContain(".wire-input-number__control");
const html = await renderComponent(source, { const html = await renderComponent(source, {
name: "quantity", name: "quantity",
value: 1, value: 1,