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 repoFix 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 deductionTwo compounding facts:
- The cleanup arrow is emitted without capturing local
id→'id' is not captured. - Even if it captured, the runtime's
create_effectisvoid()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.