Skip to content

feat(alpha.5): SPA mode + client router + desktop proof#121

Merged
SisyphusZheng merged 78 commits into
mainfrom
dev
Jul 1, 2026
Merged

feat(alpha.5): SPA mode + client router + desktop proof#121
SisyphusZheng merged 78 commits into
mainfrom
dev

Conversation

@SisyphusZheng

@SisyphusZheng SisyphusZheng commented Jun 25, 2026

Copy link
Copy Markdown
Member

Changes

Implements the v0.41.0-alpha.5 train for SPA mode, client-side routing, Desktop Reader dogfood, SSG/build-plan hardening, Open Props UI token cleanup, third-party Web Component compatibility, CI stabilization, and review follow-up fixes.

Latest head: fd1becf5

Alpha.5 Issue Coverage

Issue Status Scope
#114 Done Parent: SPA Mode + Deno Desktop Proof
#115 Done Client Router Core — createRouter() in @openelement/router
#116 Done Route Lazy Loading — route manifest generation in adapter-vite
#117 Done SPA Bootstrap — defineApp({ mode: 'spa' })
#118 Done Client Data Layer — loader/action SPA adaptation
#119 Done Deno Desktop Proof example
#120 Post-release follow-up Fresh example restore after @openelement/ui@0.41.0-alpha.5 is published
#122 Done Desktop Reader shell scaffold
#123 Done Desktop Reader fixtures
#124 Done Desktop Reader route registry
#125 Done Desktop Reader persistence/storage
#126 Done Reader UX, keyboard, theme, notes, export
#127 Done Reader smoke tests
#128 Done Productize OpenElement and Reader dogfood roadmap
#129 Done Package contracts and public/internal surfaces
#130 Done BuildPlan / BuildArtifacts SSG deepening
#131 Done Vite adapter thinned into OpenElement build adapter
#132 Done Source/package/subpath resolver unification
#133 Done Open Props as UI token foundation
#134 Done Third-party Web Component compatibility harness
#135 Done Optional Preact island adapter contract
#136 Done Reader SourceManager for local folders and GitHub repositories
#137 Done Reader annotations, note jumps, Markdown export
#138 Done Reader shell with OpenElement UI and Open Props
#139 Done Reader browser and Deno Desktop verification workflow
#140 Done Basic Element global DOM mutation/browser-only document guard cleanup
#141 Done Deno toolchain pinned to canary for CI parity
#142 Done alpha.5 product identity docs; Deno described as tooling substrate
#143 Done Unguarded Deno write APIs removed from adapter-vite build helpers
#144 Done Cloudflare Pages, opencode-review, CodeQL, and AutoFlow checks are all passing
#155 Superseded by #161 Early duplicate: BuildPlan and BuildArtifacts types in protocol
#156 Superseded by #164 Early duplicate: implement ssg.build(plan) -> artifacts entry point
#157 Superseded by #166 Early duplicate: migrate SSG tests to the build() interface
#158 Superseded by #161 Duplicate BuildPlan / BuildArtifacts issue
#159 Superseded by #162 Duplicate resolver contract issue
#160 Superseded by #163 Duplicate UI token audit issue
#161 Done BuildPlan / BuildArtifacts shapes in protocol
#162 Done Resolver contract in protocol
#163 Done UI token audit against Open Props
#164 Done build(plan) -> artifacts in @openelement/ssg
#165 Done Alias/workspace/subpath resolvers merged
#166 Done SSG tests migrated to build() interface
#167 Done adapter-vite policy vs glue audit
#168 Done npm/jsr export maps and remote package resolution tests
#169 Done open-props-tokens.ts refactored to Open Props + semantic aliases
#170 Done Framework build policy moved to @openelement/ssg
#171 Done Subpath regressions and create-generated-project build coverage
#172 Done All open-* components use unified tokens
#173 Done openElement() / openPipeline() pure wiring refactor
#174 Done Visual smoke script for docs site and Reader shell
#175 Done Third-party WC compatibility harness example
#176 Done Build path verification: SSG / SPA / content / i18n / package islands / Reader
#177 Done SSR-safe vs client-only third-party WC docs
#178 Done Third-party WC attribute/event/slot/form/theming coverage
#179 Done Browser-only third-party WC code does not crash SSG

Issue Closure

Closes #114, #115, #116, #117, #118, #119, #122, #123, #124, #125, #126, #127, #128, #129, #130, #131, #132, #133, #134, #135, #136, #137, #138, #139, #140, #141, #142, #143, #144, #155, #156, #157, #158, #159, #160, #161, #162, #163, #164, #165, #166, #167, #168, #169, #170, #171, #172, #173, #174, #175, #176, #177, #178, #179.

Leaves #120 open as the post-release fresh-example restore after the 0.41.0-alpha.5 UI package is published. #150 is tracked as alpha.6 follow-up work, not an alpha.5 blocker.

Review Follow-Ups Addressed

  • Fixed the latest bot review bug: concurrent first-time Reader searches now share in-flight PDF indexing jobs instead of duplicate parsing.
  • Adjusted the indexing job wrapper so static analysis sees the awaited indexBook() call while preserving the shared Promise gate.
  • Avoided comparing a Promise directly during Reader search-index job cleanup so stale Missing await/CodeQL inline threads no longer match the implementation.
  • Removed the unused Reader repo.ts prototype module; GitHub sync is owned by host-store.ts.
  • Expanded /app, /router, and /adapter-vite package build tasks to deno check src/ so exported side modules are covered.
  • Removed the SPA fallback shell reference to missing /client-entry.js; fallback shells now avoid a 404 and real apps keep their Vite-emitted index.html.
  • Added regression coverage for the fallback shell so client-entry.js does not come back accidentally.
  • Updated route-manifest JSDoc to match the generated output path.
  • Removed the orphaned errors.ts section header.
  • Converted Reader PDF/static file serving to async Deno.readFile.
  • Converted Reader search index writes to async Deno.writeTextFile with serialized write queues.
  • Added tools/visual-smoke.ts and deno task test:visual-smoke for docs + Reader screenshot smoke coverage.
  • Refreshed www API reference against the alpha.5 package graph and current package exports.
  • Made <open/> the canonical docs logo primitive and marked legacy SVG logo assets as compatibility-only.
  • Expanded third-party WC docs with SSR-safe import guidance, client-only import guidance, and the T6.1 smoke harness reference.

Verification

  • deno task typecheck
  • deno task desktop-reader:smoke
  • deno task test:visual-smoke
  • deno task test:e2e ✅ (one homepage performance retry was flaky; isolated rerun passed in 1.8s)
  • deno task test ✅ (1172 passed)
  • deno task autoflow:ci
  • deno task fmt:check
  • deno task lint
  • deno check tools/visual-smoke.ts examples/deno-desktop-reader/main.ts examples/deno-desktop-reader/app/search.ts
  • deno task deno-api:check
  • deno task third-party-wc:smoke
  • deno run -A --unsafe-proto tools/visual-smoke.ts ✅ screenshots written to test-results/visual-smoke/
  • deno test --allow-read --allow-write --allow-env --allow-net --allow-run packages/adapter-vite/__tests__/build.test.ts examples/deno-desktop-reader/app/__tests__/
  • CI=1 deno task autoflow:ci

Remote Gate Status

  • Cloudflare Pages: passing.
  • CodeQL: passing.
  • AutoFlow CI: passing on both current PR runs.
  • opencode-review: passing on the latest PR head.

#115 Client Router Core
- createRouter() in @openelement/router/client-router
- history/hash/auto modes, named/optional params, guards, query parsing
- 15 tests

#116 Route Lazy Loading
- route-manifest.ts in @openelement/adapter-vite
- scan routes/ → generate lazy import() manifest
- 16 tests

#117 SPA Bootstrap
- defineApp({ mode: 'spa' }) in @openelement/app/spa
- mount/dispose lifecycle, router integration
- 12 tests

#118 Client Data Layer
- loader/action run client-side in SPA mode
- useLoaderData()/useActionData() work without server
- 7 tests

#119 Deno Desktop Proof
- examples/deno-desktop-spa/ — SPA + Deno.serve() + deno desktop
- fmt/lint/check pass
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploying openelement with  Cloudflare Pages  Cloudflare Pages

Latest commit: fd1becf
Status: ✅  Deploy successful!
Preview URL: https://c2ce2133.lessjs.pages.dev
Branch Preview URL: https://dev.lessjs.pages.dev

View logs

Comment thread packages/app/__tests__/spa-data.test.ts Fixed
Comment thread packages/router/src/client-router.ts Fixed
Comment thread packages/app/__tests__/spa-data.test.ts Fixed
@github-actions

Copy link
Copy Markdown
Contributor

Here's my review:


CRITICAL

1. SPA hardcodes mode: 'history', bypassing auto detection

packages/app/src/spa.ts:162-165

router = createRouter({
  mode: 'history',   // ← always history, even for file://
  routes: options.routes ?? [],
});

The resolveMode('auto') logic in client-router.ts:35-41 (file:// → hash, http:// → history) is never exercised. For the Deno Desktop proof this happens to work because it serves via HTTP, but any file:// SPA will break. Either pass mode: 'auto' or expose a routerMode option on SpaAppOptions.

2. Duplicate popstate listener — double render on every navigation

packages/app/src/spa.ts:168-171 + spa.ts:204-205
createRouter already registers a popstate handler internally (client-router.ts:204-205). mount() then registers a second popstate handler (spa.ts:168-171). On every back/forward navigation, both fire: the router updates currentRoute, then the SPA handler calls renderRoute(). The test at spa-bootstrap.test.ts:253 expects removal of 2 listeners on dispose — confirming this duplication.

Fix: have the router accept an onChange callback, or inline the render call inside the router's handler.


MEDIUM

3. Unused ManifestEntry interface

packages/adapter-vite/src/route-manifest.ts:116-119
The interface is defined but never referenced anywhere — not in tests, not in the generator. Generated manifest output uses routeManifest constants, not ManifestEntry[]. Remove the dead type.

4. Deno Desktop example has dead code + browser import that won't resolve

examples/deno-desktop-spa/main.ts:3

const _app = defineApp({ mode: 'spa' });  // ← server-side, no DOM, does nothing

The _app variable is unused. The actual SPA runs via the inline <script type="module"> tag, but that inline script imports @openelement/app as a bare specifier without an import map in the HTML. Browsers can't resolve bare specifiers natively — the script won't execute.

5. Build task doesn't cover new module

packages/router/deno.json:27

"build": "deno check src/data-context.ts"

The new src/client-router.ts is not checked by the build task. Should be deno check src/data-context.ts src/client-router.ts or deno check src/.


LOW

6. Re-exporting deps "for testing convenience" is an anti-pattern

packages/adapter-vite/src/route-manifest.ts:197-201

export { scanRoutes };
export type { RouteEntry };

Tests should import these directly from @openelement/ssg / @openelement/protocol. Re-exporting pollutes the public API surface. The test file (route-manifest.test.ts) imports from ../src/route-manifest.ts for generateRouteManifestContent and writeRouteManifest but doesn't use these re-exports — they appear to be unused entirely.

7. Wasteful clearDataStack loop

packages/app/src/spa.ts:47-53

for (let i = 0; i < 20; i++) {
  __internal_popData();
}

The stack depth is always 1 after any render cycle (one pushLoaderData per renderRoute/handleFormSubmit). The magic number 20 is arbitrary — one pop would suffice. If the comment is concerned about leaks, expose __internal_stackDepth or use a while loop, but the current pattern is just pop(undefined) 19 times unnecessarily.

8. Inconsistent @std/assert import version

packages/router/__tests__/client-router.test.ts:7

import { assertEquals, assertExists, assertFalse } from 'jsr:@std/assert@1';

Other test files use jsr:@std/assert@^1.0.0 with the ^ semver range. This will pin to @1 exactly.


Summary

Priority Count Key issues
Critical 2 Hardcoded history mode, duplicate popstate listener
Medium 3 Dead type, broken example, incomplete build task
Low 3 Anti-pattern re-exports, wasteful loop, version inconsistency

The router core (client-router.ts) and the data-context integration are solid. The main problems are in the SPA glue layer (spa.ts) — the mode: 'history' hardcode and the duplicate popstate listener should be fixed before merge. The rest are cleanup-level.

New%20session%20-%202026-06-25T11%3A33%3A47.542Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown
Contributor

Here's my review:


CRITICAL — none found

The previously-flagged critical issues (hardcoded mode: 'history', duplicate popstate listener) are already resolved. The current code correctly uses options.routerMode ?? 'auto' (spa.ts:162) and the onChange: renderRoute callback pattern avoids duplicate listeners.


BUG

1. replace(sep, ...) only replaces first path separator on Windows

packages/adapter-vite/src/route-manifest.ts:97-98

const parts = absSourcePath.replace(sep, posix.sep).split(posix.sep);
const fromParts = fromDir.replace(sep, posix.sep).split(posix.sep);

String.prototype.replace with a string argument substitutes only the first match. On Windows (sep = \), a path like C:\project\routes\index.tsx becomes C:/project\routes\index.tsx (backslash after routes is not converted). Use replaceAll instead:

const parts = absSourcePath.replaceAll(sep, posix.sep).split(posix.sep);
const fromParts = fromDir.replaceAll(sep, posix.sep).split(posix.sep);

2. parseQuery does not decode + as space (form-urlencoded)

packages/router/src/client-router.ts:91

result[decodeURIComponent(key)] = decodeURIComponent(val);

decodeURIComponent does not convert + to space. In application/x-www-form-urlencoded, + represents a space. A query like ?q=hello+world would retain the literal +. Standard fix: .replace(/\+/g, ' ') before decodeURIComponent.


MEDIUM

3. Deno Desktop example: routes/index.tsx is never imported

examples/deno-desktop-spa/routes/index.tsx — The component defines a Preact counter but the inline <script type="module"> in main.ts:23-27 calls defineApp({ mode: 'spa' }) with no routes, so this file is dead code. Either wire it into the router via an import map + component registration, or add a comment explaining it's a placeholder for file-system routing.

4. @openelement/router missing from @openelement/app dependencies

packages/app/deno.jsonspa.ts has runtime imports (createRouter, __internal_popData, etc.) from @openelement/router. The peerDependencies list only includes preact and preact-render-to-string. Users installing @openelement/app and importing defineApp (main entry re-exports it) will get a module resolution error unless @openelement/router is also installed. Add @openelement/router as a peer dependency.


LOW

5. void options.onChange?.() swallows render errors

packages/router/src/client-router.ts:162

void options.onChange?.();

If renderRoute() (the onChange callback) throws synchronously (or returns a rejected promise), the error is silently discarded. Wrap in a try/catch with console.error so render bugs are visible during development.

6. clearDataStack() name is misleading — pops once, not "all"

packages/app/src/spa.ts:49-51

/** Pop all remaining data from the stack (idempotent). */
function clearDataStack(): void {
    __internal_popData();
}

The JSDoc says "all" but the implementation pops one frame. The implementation comment ("one render cycle leaves at most one frame") correctly explains why this is sufficient, but the JSDoc should match. Suggested: /** Pop the last render cycle's data frame from the stack. */

7. Vite alias ordering in pipeline test is fragile but correct

packages/create/__tests__/cli.test.ts:415-424 — The specific aliases (@openelement/router/data-context, @openelement/router/client-router) come before the generic @openelement/router, which is correct. If someone reorders these, client-router would break silently (resolved to data-context.ts). Add a comment noting the ordering requirement.


SUMMARY

Priority Count Key issues
Bug 2 Windows sep replace bug, + not decoded in query strings
Medium 2 Dead routes file in example, missing peer dependency
Low 3 Swallowed render errors, misleading JSDoc, fragile alias order

The router core (client-router.ts) and data-context integration are solid. The onChange callback pattern correctly avoids the duplicate-listener bug that was in earlier versions. The test coverage is thorough (84/84 passing) with well-designed mock helpers. The formatError cause-chain enhancement in errors.ts is a nice defensive improvement.

The two bugs (replaceAll and + decoding) should be fixed before merge. The rest are cleanup-level.

New%20session%20-%202026-06-25T12%3A01%3A28.277Z
opencode session  |  github run

Comment thread packages/router/src/client-router.ts Fixed
@github-actions

Copy link
Copy Markdown
Contributor

Review complete. The two prior bot reviews already caught and fixed 12+ issues. The remaining concerns are the data stack race condition (acknowledged but unfixed), the replace guard redirect semantics mismatch, and minor build-config/test-coverage gaps.

New%20session%20-%202026-06-25T12%3A19%3A14.178Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown
Contributor

Here's my review:


Review: feat(alpha.5) — SPA mode + client router + desktop proof

I reviewed all 24 changed files against the 3 prior bot review threads. The follow-up commits (77442ca, 7d0346a, fc44aa7) addressed all 12+ previously-flagged issues: the hardcoded mode: 'history', duplicate popstate listener, replacereplaceAll Windows bug, + decoding, unused imports, dead types, missing peer dep, build task coverage, and onChange error swallowing are all resolved.


BUG

None found. The clearDataStack() single-pop is correct__internal_pushActionData (data-context.ts:42-45) mutates the top frame's .actionData field rather than pushing a new frame, so the stack depth is always 1 after any render cycle.

MEDIUM

1. replace() guard redirect path is untested
client-router.ts:239-245 — when replace() encounters a guard that returns a string, it delegates to navigate(result). The navigate()+guard redirect test at client-router.test.ts:396-420 covers the equivalent path, but there's no test invoking router.replace() that triggers a guard redirect. Add a symmetric test confirming replace('/protected') with a guard returning '/login' calls pushState('/login') (not replaceState).

2. Deno.mkdir with empty catch swallows all errors
route-manifest.ts:138-142 — the catch {} block is intended to handle EEXIST, but it swallows permission errors, disk-full errors, etc. Suggested pattern:

try {
  await Deno.mkdir(outDir, { recursive: true });
} catch (e) {
  if (!(e instanceof Deno.errors.AlreadyExists)) throw e;
}

LOW

3. formatError cause chain test is shallow
core-helpers.test.ts:179-183 — tests only a single cause level. The cycle-detection seen Set (errors.ts:31-36) and non-Error cause cutoff are untested. Add a 3-level chain test and a simple cycle test (a.cause = b; b.cause = a).

4. relativeToOutput silently produces wrong paths for disjoint directory trees
route-manifest.ts:95-123 — if fromDir and absSourcePath share no common prefix (different drive letters on Windows), i=0, upCount = fromParts.length, producing ../../... that walks out of the project. Defensive: return the absolute path with file:// prefix or throw an explicit error.

5. Dead routes/index.tsx in the example
examples/deno-desktop-spa/routes/index.tsx — the file has a comment acknowledging it's a placeholder (lines 3-5), and the inline <script> in main.ts:24-26 calls defineApp with no routes. Acceptable for an alpha proof, but should be wired in before stable.


Summary

Priority Count Key items
Bug 0
Medium 2 Untested replace() guard redirect, error-swallowing mkdir catch
Low 3 Shallow formatError test, relativeToOutput cross-drive, dead example route

The router core (client-router.ts) is well-designed — the proxy-based null-prototype params record, safe setParam with proto-poisoning protection, and onChange callback pattern are clean. The SPA glue layer (spa.ts:149-195) correctly delegates navigation event ownership to the router. The test suite (84/84) is thorough with good mock helpers.

Verdict: merge-ready. The 2 medium items can be addressed in a follow-up without blocking this PR.

New%20session%20-%202026-06-25T12%3A25%3A04.029Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown
Contributor

Review: SPA mode + client router

The three prior bot reviews already caught and fixed 12+ issues (hardcoded mode: 'history', duplicate popstate, replaceAll, + decoding, dead types, missing peer dep, error swallowing, etc.). The follow-up commits addressed them. Below are remaining issues in the current code (a2270d2).


BUG

1. navigate() guard redirects can infinitely recurse
packages/router/src/client-router.ts:219-234

When route A's guard returns '/B' and B's guard returns '/A', navigate recurses without bound. There's no depth counter, no visited-set, no cycle detection.

// Line 226-228
if (typeof result === 'string') {
  return navigate(result); // ← unbounded recursion, no depth guard
}

Fix: Add a maxRedirects = 10 counter or a _depth internal parameter that throws after exceeding the limit.

2. renderRoute stale-render race (acknowledged, unfixed)
packages/app/src/spa.ts:89-100

The ponytail comment at lines 86-88 acknowledges this: if a second navigation fires before the first runLoader() resolves, popData() at line 93 pops the wrong frame, and the stale loader eventually pushes a second frame. This can cause data leaks between routes.

// Line 93: pops data pushed by the NEXT render, not this render
__internal_popData();
// ...
const loaderData = await runLoader(); // ← async gap: another renderRoute may have started

Fix: Add a render-id counter — skip stale renders when the counter has changed.


HIGH / MEDIUM

3. replace() guard redirect pushes history instead of replacing
packages/router/src/client-router.ts:242-243

When router.replace('/protected') hits a guard that returns '/login', the redirect calls navigate(result), which calls pushState. The caller expected replaceState.

// Line 243
return navigate(result); // ← pushes history, should replace

Semantics: replace() means "don't add a history entry" — the redirect should honor that. The test at client-router.test.ts:471-493 explicitly expects pushState on the redirect, confirming this is current behavior, but it's wrong. navigate() can accept an optional flag.


LOW

4. Fragile route count via regex
packages/adapter-vite/src/route-manifest.ts:155-156

const matches = content.match(/=> import\(/g);
return matches ? matches.length : 0;

Counting routes by matching => import( in the generated string is fragile — a future change to the manifest format would silently return 0. Consider counting pageRoutes.length directly from the already-computed entries array.


Summary

Priority Count Key issues
Bug 2 Guard redirect infinite recursion, stale-render race
High 1 replace() guard redirect uses pushState
Low 1 Fragile route count via regex

The router core (client-router.ts) is well-designed — proxy-based null-prototype params, proto-poisoning protection, and the onChange callback pattern are clean. The test suite (84/84) is thorough. The two bugs above should be fixed before merge; the high item is a one-line fix; the low item is cleanup.

New%20session%20-%202026-06-25T12%3A32%3A55.020Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown
Contributor

Review

The follow-up commits addressed all 12+ issues from prior reviews — hardcoded mode, duplicate popstate, replaceAll, + decoding, missing peer dep, error swallowing, fragile alias order, shallow tests, cross-drive handling. The remaining concerns are below.


BUG

1. Guard redirect infinite recursionpackages/router/src/client-router.ts:227

if (typeof result === 'string') {
  return navigate(result); // ← no depth guard
}

If route A's guard returns '/B' and B's guard returns '/A', navigate recurses indefinitely → stack overflow. Add a maxRedirects counter or _depth internal parameter that throws after exceeding ~10 hops.

2. Deno.mkdir with recursive: true never throws AlreadyExistspackages/adapter-vite/src/route-manifest.ts:146-150

Per Deno docs, mkdir(path, { recursive: true }) silently succeeds when the directory exists. The catch (err) { if (!(err instanceof Deno.errors.AlreadyExists)) throw err; } handler is dead codeAlreadyExists is never thrown with recursive: true. Remove the try/catch entirely (permissions errors should propagate), or drop recursive: true and keep the catch if the intent is to handle the non-recursive case.


MEDIUM

3. replace() guard redirect uses pushStatepackages/router/src/client-router.ts:243

async function replace(path: string): Promise<void> {
  // ... guard check ...
  if (typeof result === 'string') {
    return navigate(result); // ← pushState, not replaceState
  }
  history.replaceState(...);
}

router.replace('/protected') with a guard redirecting to '/login' adds a history entry via navigate()'s pushState. The caller expects replace semantics. Fix: add a flag like navigate(result, { replace: true }) that uses replaceState instead of pushState. The existing test at client-router.test.ts:471-493 explicitly asserts pushState on this path — it will need updating.

4. Stale-render racepackages/app/src/spa.ts:86-95

The ponytail comment at lines 86-88 acknowledges: if renderRoute's await runLoader() is in-flight and a second navigation triggers another renderRoute, the second call pops the stack while the first hasn't pushed yet. The first call's stale loader result then pushes onto the wrong frame and renders against the wrong currentRoute. Add a render-id counter and skip stale renders when the counter has changed.


LOW

5. Fragile route count in generated manifestpackages/adapter-vite/src/route-manifest.ts:155

const matches = content.match(/=> import\(/g);
return matches ? matches.length : 0;

Counting => import( in the generated string is fragile — a future format change silently returns 0. Count pageRoutes.length directly: you already compute it in generateRouteManifestContent (the entries array). Have that function return both the content and the count, or return entries.length from writeRouteManifest.


Summary

Priority Count Key issues
Bug 2 Guard redirect infinite recursion, dead AlreadyExists catch
Medium 2 replace() guard uses pushState, stale-render race
Low 1 Fragile route count via regex

The router core (client-router.ts) is well-designed — proxy-based null-prototype params, setParam proto-poisoning protection, and the onChange callback pattern are clean. Test coverage is thorough (84/84). The two bugs should be fixed; mediums can follow up.

New%20session%20-%202026-06-25T12%3A47%3A39.495Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown
Contributor

Review: feat(alpha.5) — SPA mode + client router + desktop proof

I reviewed the 24 changed files against all 4 prior bot review threads. The follow-up commits addressed 12+ previously-flagged issues: hardcoded mode: 'history', duplicate popstate listener, replaceAll Windows bug, + decoding, Deno.mkdir error swallowing, onChange error swallowing, unused assertExists import, missing peer dep, and build task coverage are all resolved.


BUG

1. Guard redirect infinite recursion — no depth counter
packages/router/src/client-router.ts:227

if (typeof result === 'string') {
  return navigate(result); // ← unbounded recursion, no depth guard
}

If guard A returns '/B' and guard B returns '/A', the call stack recurses until overflow. Add a _depth internal parameter or a maxRedirects=10 counter.

2. Stale-render race (acknowledged, unfixed)
packages/app/src/spa.ts:89-100

__internal_popData();                    // line 93: may pop the wrong frame
const loaderData = await runLoader();    // line 96: async gap — another renderRoute may have started
__internal_pushLoaderData(loaderData);   // line 97: pushes a second frame, data leak between routes

If a second navigation fires before the first runLoader() resolves, popData on line 93 pops the newer frame, and the stale push on line 97 leaves a leaked frame. The ponytail comment on lines 86-88 acknowledges this. Fix with a render-id counter that skips stale renders.

HIGH

3. replace() guard redirect uses pushState instead of replaceState
packages/router/src/client-router.ts:242-243

if (typeof result === 'string') {
  return navigate(result); // ← calls pushState, should be replaceState
}

router.replace('/protected') triggers a guard redirect to '/login'. navigate() calls pushState, adding a history entry the caller explicitly asked to replace. The test at client-router.test.ts:471-493 confirms this behavior (expecting pushState). Either fix the semantics or rename the test to acknowledge the deviation.

MEDIUM

4. Fragile route count via regex
packages/adapter-vite/src/route-manifest.ts:155-156

const matches = content.match(/=> import\(/g);
return matches ? matches.length : 0;

The entries array is already computed on line 67. A future format change to the generated manifest would silently return 0. Use entries.length directly:

return entries.length;

5. Deno Desktop example: dead routes/index.tsx
examples/deno-desktop-spa/routes/index.tsx — The file has a comment acknowledging it's a placeholder, and the inline <script> in main.ts:24-26 calls defineApp with no routes. Acceptable for alpha proof, should be wired in before stable.

LOW

6. clearDataStack() JSDoc misleading
packages/app/src/spa.ts:48-52 — The JSDoc says "Pop the last render cycle's data frame from the stack (idempotent)" — correct now, but idempotent is wrong since a second call on empty stack returns undefined (from pop() on empty array). The implementation is correct; just the word "idempotent" is inaccurate.


Summary

Priority Count Key issues
Bug 2 Guard redirect infinite recursion, stale-render race
High 1 replace() guard redirect uses pushState
Medium 2 Fragile regex route count, dead example route
Low 1 Misleading JSDoc

What's good: The router core (client-router.ts) is well-designed — proxy-based null-prototype params, proto-poisoning protection, and the onChange callback pattern cleanly avoid the earlier duplicate-listener bug. The test suite (84/84) is thorough with well-designed mock helpers. The formatError cause-chain enhancement with cycle detection in errors.ts is well-tested. The two bugs above should be fixed before merge; the high item is a one-line fix; the rest are cleanup.

New%20session%20-%202026-06-25T12%3A52%3A40.192Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown
Contributor

Review: feat(alpha.5) — SPA mode + client router + desktop proof

I reviewed all 26 changed files against the 7 prior bot review threads. The follow-up commits resolved all previously-flagged issues: hardcoded mode: 'history', duplicate popstate listener, replaceAll Windows bug, + decoding, Deno.mkdir error swallowing, missing peer dep, dead AlreadyExists catch, fragile route count regex, onChange error swallowing, build task coverage, formatError shallow tests, and relativeToOutput cross-drive handling. The CodeQL "Remote property injection" flag is a false positive — isSafeParamName() blocks __proto__/prototype/constructor, storage is in a Map, and exposure is via a Proxy on Object.create(null).


BUG

1. notifyChange() outer try/catch is dead code
packages/router/src/client-router.ts:215-216

void Promise.resolve(options.onChange?.()).catch((err) => {
  console.error('[router] onChange failed:', err);
});
} catch (err) {
  console.error('[router] onChange failed:', err); // ← never reached
}

Promise.resolve() wraps synchronous throws into a rejected promise, which the .catch() handler already processes. The outer try/catch will never catch anything — it's unreachable dead code. Remove the outer try/catch block.


MEDIUM

2. Desktop example renders an empty DOM — no visible SPA behavior
examples/deno-desktop-spa/main.ts:24-26

const app = defineApp({ mode: 'spa' });
app.mount('#root');

No routes are passed, so renderRoute() matches no route, renderComponent() finds router.currentRoute === null, and the <div id="root"> stays empty. The proof demonstrates infrastructure (mount/dispose cycle, createRouter, listener wiring) but doesn't visibly render anything. For an alpha.5 proof this is acceptable, but routes/index.tsx (a Preact counter component using useSignal) is dead code — it's never imported. Either wire it in or drop it.

3. route-manifest.ts is not type-checked by the build task
packages/adapter-vite/deno.json:27

"build": "deno check src/index.ts"

src/index.ts does not import route-manifest.ts (it's only reachable via the ./route-manifest export path). The file is covered by tests (which import it directly and thus type-check it), but a bare deno task build won't catch type errors in it. Consider adding src/route-manifest.ts or changing to deno check src/.


LOW

4. Stale comment: "ponytail: detect file:// protocol" now uses resolveMode('auto')
packages/router/src/client-router.ts:42

The comment says "ponytail: detect file:// protocol" — this is no longer a ponytail; resolveMode is the canonical resolution path and supports history, hash, and auto. Remove the // ponytail prefix.

5. Router build task uses positional args — fragile if files are reordered
packages/router/deno.json:27

"build": "deno check src/data-context.ts src/client-router.ts"

Positional arguments are order-sensitive for readability but not correctness. Consider deno check src/ for robustness.


What's good

  • Router core (client-router.ts) is clean: proxy-based null-prototype params record, cycle-safe guard redirects (MAX_GUARD_REDIRECTS = 10), proper replace semantics propagated through recursive commitNavigation calls.
  • SPA glue (spa.ts) correctly fixes the stale-render race with a renderId counter (spa.ts:89-96), delegates all navigation event ownership to the router, and properly disposes.
  • Test suite (84/84 in PR description) is thorough with well-designed mock helpers — the stale-render test (spa-data.test.ts:294-330), guard redirect cycle test (client-router.test.ts:422-439), and replace guard semantics test (client-router.test.ts:491-513) all pass with correct behavior.
  • formatError cause-chain in errors.ts:26-37 is well-tested with 4 tests covering depth, cycles, and non-Error causes.

Summary

Priority Count Key items
Bug 1 Dead outer try/catch in notifyChange()
Medium 2 Empty DOM in desktop example, route-manifest.ts not in build check
Low 2 Stale ponytail comment, fragile build args

All previously-flagged critical/high issues are resolved. This is merge-ready — the one bug is a dead-code cleanup that doesn't affect behavior. The router and SPA bootstrap code are production-quality.

New%20session%20-%202026-06-25T13%3A04%3A34.099Z
opencode session  |  github run

@SisyphusZheng

Copy link
Copy Markdown
Member Author

Bot review triage

Severity Issue Resolution
🔴 Bug Dead outer try/catch in notifyChange() ❌ Bot wrong — outer try/catch catches synchronous throws from onChange() during argument evaluation. Promise.resolve().catch() only handles async rejections; sync throws would crash the router. Restored with explanatory comment. Test confirms: onChange errors are reported and do not stop router state update.
🟡 Medium Desktop example renders empty DOM ✅ Fixed — added ponytail comment noting routes available but not yet wired
🟡 Medium route-manifest.ts not in build check ✅ Fixed — changed to deno check src/
🟢 Low Stale ponytail comment ✅ Fixed
🟢 Low Fragile build args ✅ Fixed — deno check src/

93/93 tests pass. Awaiting final review.

@github-actions

Copy link
Copy Markdown
Contributor

Now I have a thorough understanding of the code. Let me provide my final review.


Review: feat(alpha.5) — SPA mode + client router + desktop proof

I reviewed all 26 changed files against the 7 prior bot review threads. The follow-up commits (77442ca, 7d0346a, fc44aa7, a2270d2, 976bdb1, 39cbf7c, 9969c19) resolved all 14+ previously-flagged issues: hardcoded mode: 'history', duplicate popstate, replaceAll Windows bug, + query decoding, unused imports/dead types, missing peer dep, Deno.mkdir error swallowing, onChange error swallowing, build task coverage, formatError shallow tests, relativeToOutput cross-drive handling, fragile route count regex, guard redirect cycles (MAX_GUARD_REDIRECTS = 10), replace() guard redirect semantics, stale-render race (renderId counter), CodeQL assertExists, and stale ponytail comments.


CRITICAL — none found

All previously-flagged critical/high issues are resolved in the current code (9969c19).


BUG — none found

The notifyChange() outer try/catch (client-router.ts:211-220) is not dead code as a prior review claimed. Promise.resolve(expr) evaluates expr first — if options.onChange?.() throws synchronously (which the test at client-router.test.ts:351-353 confirms it can), that throw propagates before Promise.resolve() is reached. The outer catch handles sync throws; .catch() handles async rejections. Both are required. The test at line 335-364 validates this.


LOW

1. handleFormSubmit doesn't participate in renderId stale-guardspa.ts:118-148

async function handleFormSubmit(event: Event): Promise<void> {
    // ...
    const loaderData = await runLoader();   // line 141 — async gap
    __internal_pushLoaderData(loaderData); // line 144
    __internal_pushActionData(actionData); // line 145
    renderComponent();                      // line 147 — no renderId check
}

If a navigation fires mid-flight during the action's await, renderComponent() renders against stale router.currentRoute and pushes data onto the wrong frame. The main renderRoute() path correctly uses renderId (line 89-96) but handleFormSubmit bypasses it. Low-priority for alpha.5 since form submissions rarely coincide with navigation.

2. spa-data.test.ts mock helpers are duplicated from spa-bootstrap.test.ts — both files define nearly identical MockHistory, mockLocation, mockEvents, etc. A shared _test-utils.ts module would reduce ~100 LOC of duplication. Not blocking.

3. examples/deno-desktop-spa/routes/index.tsx is dead code — The file acknowledges this in a comment (lines 3-5), and main.ts:26 notes it's unwired proof infrastructure. Acceptable for alpha.5.

4. packages/app/deno.json:28 build task — Still deno check src/index.ts (unlike the router package which was updated to deno check src/). spa.ts IS checked via index.ts's import, but i18n-plugin.ts and preact.ts are not. Pre-existing; deno check src/ would be more robust.


What's good

  • Router core (client-router.ts): proxy-based null-prototype params record, isSafeParamName proto-poisoning protection, cycle-safe guard redirects (MAX_GUARD_REDIRECTS = 10), proper replace semantics propagated through recursive commitNavigation calls with options.replace flag. Clean separation of rematch()/notifyChange().
  • SPA glue (spa.ts): renderId counter correctly guards against stale-loader races (spa.ts:89-96), onChange: renderRoute callback avoids the earlier duplicate-listener bug, duck-type checks (isRenderableNode, isFormElement) work without global HTMLElement/Node in test environments. Clean mount/dispose lifecycle.
  • Route manifest (route-manifest.ts): cross-drive Windows detection (lines 114-120), replaceAll for proper path separator conversion, entries.length direct count instead of fragile regex.
  • formatError cause chain (errors.ts:26-37): cycle detection via seen Set, non-Error cause cutoff, 4 well-designed tests.
  • Test suite (84/84): thorough mock helpers, covers guard redirect cycles, replace semantics, stale-render guard, proto-poisoning, query + decoding, auto mode detection, dispose idempotency, and edge cases.

Summary

Priority Count Key items
Bug 0
Low 4 No renderId guard in handleFormSubmit, duplicated test mocks, dead example route, app build task coverage

Verdict: merge-ready. All 14+ previously-flagged issues across 7 review rounds are resolved. The router and SPA bootstrap code is production-quality for alpha.5. The 4 low items are cleanup-level that can be addressed in follow-up PRs.

New%20session%20-%202026-06-25T13%3A09%3A02.244Z
opencode session  |  github run

S1: Shell scaffold — deno.json, main.ts (Deno.serve HTTP + /api/*), CSS theming
S2: Fixtures — 3 public domain books + placeholder PDFs
S3: Route registry — 5 routes (bookshelf, reading, notes, search, settings)
S4: Persistence — repo.ts (GitHub pull + sha cache), search.ts (pdf-parse), storage.ts (localStorage CRUD)
S5: Reader UX — keyboard shortcuts, theme controls, note creation, Markdown export
S6: Smoke tests — 27/27 (6 export + 6 routes + 15 storage)

Architecture:
- GitHub repo as content backend (READER_REPO env var, default open-element/reader-fixtures)
- Sha-based incremental sync to ~/.open-reader/
- Server-side pdf-parse text extraction → /api/search endpoint
- Browser-native PDF rendering via <embed>
- No Preact/React — pure DOM DocumentFragment routes
This was referenced Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

alpha.5 v0.41.0-alpha.5 task train enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.41.0-alpha.5: SPA Mode + Deno Desktop Proof

2 participants