productionby PIYUSHUpdated Sep 19, 2026

Bug Report — 2026-09-19

Running bug log from the budget example build (examples/budget). Found while dogfooding current, shipped Morph features at head.

Legend: 🟢 open · 🟠 open w/ workaround · 🟣 docs mismatch · ✅ fixed

# Area Severity Status Title
1 Install/cache high ✅ Stale cached runtime wins over the (newer) local repo runtime
2 Feature set high ✅ CSS position from a stylesheet never enables MORPH_FEATURE_POSITION
3 C++ interop medium ✅ import path of *.cpp resolves against the importing module dir, not the project root
4 Effects high ✅ morphEffect cleanup-return doesn't compile / isn't a runtime contract (docs overstate it)
5 Types low ✅ JsString + JsValue operator missing ("" + payload.field won't compile)
6 Parser low ✅ Entry must be export default function App() — error message points at it correctly

1 — Stale cached runtime wins over local repo runtime

Area: morph install (crates/morphc/src/commands/install.rs:36), morph-cache::download_runtime Severity: high — builds silently link an old runtime with a different layout/ABI than the repo's runtime/cpp.

What happened: morph build printed Runtime not linked, linking from cache... and pulled ~/.morph/cache/runtimes/cpp/v0.1.0 (released tarball, old layout core/node/style.h) instead of the repo's runtime/cpp/ (new layout style/style.h + style/features/). The build then failed because animation.cpp refers to node->style.left/top, which exist under MORPH_FEATURE_POSITION only in the new runtime — the old runtime's struct didn't match the codegen.

Why local is skipped: install.rs:36 checks is_runtime_cached() first and returns early. The "nearby local runtime" branch lives inside download_runtime() (cache.rs:60-81), which is never reached when the cache already has the pinned version. So a stale cache always shadows the fresher in-repo runtime — even though find_local_runtime() would match it (manifest version == config pin).

Workaround (used):

rm -rf ~/.morph/cache/runtimes/cpp/v0.1.0
morph install   # → "Using local runtime ../../runtime/cpp (v0.1.0)" → re-caches from repo

Fix candidates: install.rs should prefer a matching nearby local runtime over a cached release (e.g. check find_local_runtime first in install), or ensure_runtime() in build/dev should verify the linked runtime's layout against runtime/cpp when inside the repo.

Fixed: download_runtime now checks the local runtime first and re-syncs the cache when it differs (clearing stale files); install and ensure_runtime both prefer a version-matching local runtime, refreshing a stale cache + relink as needed. Unit tests in morph-cache cover stale-file clearing, manifest-insensitive hashing, and no-op sync.

2 — Stylesheet position never enables MORPH_FEATURE_POSITION

Area: crates/morph-codegen/src/feature_set.rs (scan_style:31-36, scan_reactive:105) Severity: high — the app that needs position compiles a runtime missing it.

What happened: .toast { position: fixed } in src/style.css produced no MORPH_FEATURE_POSITION define, so the compiled MorphStyle had no left/top members, and the runtime's animation.cpp failed: 'struct MorphStyle' has no member named 'left'.

Why: the feature scanner only reads position from reactive styles (inline style={{...}}) and keyframes (raw left/top), plus IRStyle.position — and the stylesheet→IR path evidently does not populate IRStyle.position from a position: fixed rule.

Workaround (used): set the fixed positioning via inline reactive style, e.g. style={{ position: "fixed", bottom: 22, right: 22 }}; reactive position keys are scanned (feature_set.rs:105) and enable the feature.

Note: layout.rs handles position: absolute/fixed/relative/sticky fully; only the feature detection is blind to stylesheet rules.

Fixed: FeatureSet::scan_css_map feeds every collected stylesheet rule through the same mapping conditional-class swaps use, and morph build scans all CSS rules after the window walk (derived dirty_rendering / transform implications apply to both paths). Dynamic-class cases like the toast now link MORPH_FEATURE_POSITION with no reactive workaround.

3 — *.cpp import paths resolve against the importing module dir

Area: crates/morphc/src/commands/build.rs:260 (resolved.dir.join(path)) Severity: medium — confusing relative-path semantics; docs describe project-root resolution.

What happened: import { osTime } from '../cpp/clock/clock.cpp' from src/components/Clock.mx emitted:

#include "/…/examples/budget/src/components/../cpp/clock/clock.cpp"   // = src/cpp/… — missing!

Had to use ../../cpp/clock/clock.cpp to reach the project-root cpp/. The absolute include is correct, but the anchor (importing module dir) makes sibling-of-root paths unintuitive.

Workaround: treat cpp import paths as relative to the importing .mx file and traverse up to the project root; keep all shared cpp at cpp/ and import as ../../cpp/… from src/components/….

Fixed: the IR builder now resolves .cpp imports against the importing module's directory first, then the project root (same two-anchor rule component/CSS/TS imports use; IRBuilder::with_project_root, wired in build and dev). cpp/clock/clock.cpp works from anywhere; documented in docs/guides/native-cpp.md.

4 — morphEffect cleanup-return doesn't compile; docs overstate cleanup

Area: runtime reactivity/effect.cpp:25 (create_effect(std::function<void()>)), crates/morph-codegen/src/cpp/mod.rs:191-198; docs docs/dev/api/morphEffect claim "cleanup". Severity: high — a commonly-reached affordance fails to compile.

What happened: effect body return () => clearInterval(id) generated:

auto id = morph::set_interval(…);
return []() -> auto { return morph::clear_timer(id); };   // 'id' not captured + bad return deduction

Two compounding facts:

  1. The cleanup arrow is emitted without capturing local id → 'id' is not captured.
  2. Even if it captured, the runtime's create_effect is void() and never stores a returned cleanup — the returned value would be discarded, so per-run cleanup can't work by design today.

Doc api/morphEffect says "side effects with cleanup and dependency tracking" — the cleanup half is not implemented.

Workaround (used): effect bodies are plain void callbacks. Timers scheduled inside a run-once effect live for the app's lifetime (fine for the always-mounted Clock/Toast/IP badge). Auto-dismiss is re-triggered by a dep counter bump. Update api/morphEffect docs to drop the cleanup claim (or implement cleanup storage on EffectNode + capture in codegen).

Fixed (cleanup implemented): the runtime now honors the contract — create_effect overloads route cleanup-returning callbacks to create_effect_cleanup (stored per run, executed before re-run and on destroy; mount-scoped + dep-guarded dev variants included). Codegen fixed both compile failures: nested arrows capture enclosing locals via shared_ptr (escape-analysis pre-registration + setTimeout/setInterval typed int), and () => clearInterval(id) emits -> void instead of return <void> from -> auto. Verified with a linked g++ run (re-run/destroy ordering) plus translator unit tests. Run-once ([]) effects stay fire-and-forget (cleanup dropped) — documented in docs/api/morphEffect.md, whose Timer example now demos genuine cleanup.

5 — No JsString + JsValue operator

Area: generated JsString/JsValue operators in the compiled unit (see app.cpp inline helpers) Severity: low

What happened: setMessage("" + payload.message) → no match for operator+; only operator+(const JsValue&, const JsString&) exists.

Workaround: assign the JsValue directly (setMessage(payload.message)), or put the string literal on the right (payload.message + "").

Fixed: const char*/std::string × JsValue operator+ overloads added in runtime/cpp/types/js_value.h (both directions, returning JsString). "" + payload.message was an ambiguous overload; verified with a g++ compile check (fails before, passes after).

6 — Entry must be export default function App()

Area: crates/morph-* parser (morph build entry validation) Severity: informational — the error message is accurate and actionable.

Observation: export function App() gives error: entry … has no default-export component (expected export default function App()). windowConfig via export const windowConfig = {...} is the convention for window geometry. Not a bug — recorded for completeness (examples always use export default).


Log is append-only while the budget example is built; add new findings at the top of the table.