@kumikijs/runtime 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -23,6 +23,14 @@ type KumikiElementOptions = {
23
23
  * apply, matching a standalone Kumiki page).
24
24
  */
25
25
  shadow?: boolean;
26
+ /**
27
+ * Routing source forwarded to mount (#36). An embedded element rarely owns the
28
+ * top-level URL, so a routed Kumiki app inside it should usually use
29
+ * `"memory"` (an in-memory path) rather than the default `"history"`, which
30
+ * reads/writes the host page's location/history.
31
+ */
32
+ router?: "history" | "memory"; /** Initial path for the memory router (default `"/"`). */
33
+ initialPath?: string;
26
34
  };
27
35
  /**
28
36
  * Register `app` as the custom element `tagName`. Idempotent: if the tag is
@@ -89,6 +97,8 @@ type ScenarioReport = {
89
97
  };
90
98
  declare function runScenario(app: AppShape, root: HTMLElement, scenario: Scenario, opts?: {
91
99
  settleMs?: number;
100
+ router?: "history" | "memory";
101
+ initialPath?: string;
92
102
  }): Promise<ScenarioReport>;
93
103
  //#endregion
94
104
  //#region src/smoke.d.ts
@@ -324,6 +334,15 @@ type MountOptions = {
324
334
  * passes its in-shadow container so theming stays encapsulated.
325
335
  */
326
336
  styleHost?: HTMLElement;
337
+ /**
338
+ * Routing source (#36). `"history"` (default) reads/writes the ambient
339
+ * document `location` / `history`. `"memory"` holds the current path in
340
+ * memory and never touches `history.*` — for embedded / sandboxed hosts (the
341
+ * docs playground `srcdoc`, a Web Component) where the Kumiki app does not own
342
+ * the top-level URL and `history.pushState` throws in an opaque origin.
343
+ */
344
+ router?: "history" | "memory"; /** Initial path for the memory router (default `"/"`). Ignored in history mode. */
345
+ initialPath?: string;
327
346
  };
328
347
  type RouteEntry = {
329
348
  pattern: string; /** Returns the TileNode for this route given the current state. */
package/dist/index.js CHANGED
@@ -36,6 +36,8 @@ function defineKumikiElement(tagName, app, options = {}) {
36
36
  this.app = makeApp();
37
37
  let target = this;
38
38
  const mountOpts = { providers: buildProviders(this) };
39
+ if (options.router) mountOpts.router = options.router;
40
+ if (options.initialPath !== void 0) mountOpts.initialPath = options.initialPath;
39
41
  if (options.shadow) {
40
42
  const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
41
43
  root.replaceChildren();
@@ -127,9 +129,12 @@ async function runScenario(app, root, scenario, opts = {}) {
127
129
  };
128
130
  };
129
131
  const dispatchable = app;
132
+ const mountOpts = {};
133
+ if (opts.router) mountOpts.router = opts.router;
134
+ if (opts.initialPath !== void 0) mountOpts.initialPath = opts.initialPath;
130
135
  try {
131
136
  try {
132
- mount(app, root);
137
+ mount(app, root, mountOpts);
133
138
  } catch (e) {
134
139
  steps.push(mkStep(void 0, "mount", [`mount threw: ${errStr$1(e)}`], [], app, root, []));
135
140
  return finish();
@@ -464,6 +469,69 @@ var KumikiPanic = class extends Error {
464
469
  function isPanic(e) {
465
470
  return e instanceof KumikiPanic || typeof e === "object" && e !== null && e.isKumikiPanic === true;
466
471
  }
472
+ function historyRouter() {
473
+ return {
474
+ read: () => ({
475
+ pathname: location.pathname,
476
+ search: location.search,
477
+ hash: location.hash
478
+ }),
479
+ push: (p) => history.pushState(null, "", p),
480
+ replace: (p) => history.replaceState(null, "", p),
481
+ back: () => history.back(),
482
+ subscribe: (cb) => {
483
+ const h = () => cb();
484
+ window.addEventListener("popstate", h);
485
+ return () => window.removeEventListener("popstate", h);
486
+ }
487
+ };
488
+ }
489
+ /** Split a raw path into the `{ pathname, search, hash }` parseLocation reads. */
490
+ function splitPath(p) {
491
+ let rest = p || "/";
492
+ let hash = "";
493
+ const hi = rest.indexOf("#");
494
+ if (hi !== -1) {
495
+ hash = rest.slice(hi);
496
+ rest = rest.slice(0, hi);
497
+ }
498
+ let search = "";
499
+ const qi = rest.indexOf("?");
500
+ if (qi !== -1) {
501
+ search = rest.slice(qi);
502
+ rest = rest.slice(0, qi);
503
+ }
504
+ return {
505
+ pathname: rest || "/",
506
+ search,
507
+ hash
508
+ };
509
+ }
510
+ function memoryRouter(initialPath = "/") {
511
+ const stack = [initialPath || "/"];
512
+ const listeners = /* @__PURE__ */ new Set();
513
+ return {
514
+ read: () => splitPath(stack[stack.length - 1] ?? "/"),
515
+ push: (p) => {
516
+ stack.push(p);
517
+ },
518
+ replace: (p) => {
519
+ stack[stack.length - 1] = p;
520
+ },
521
+ back: () => {
522
+ if (stack.length > 1) {
523
+ stack.pop();
524
+ for (const l of listeners) l();
525
+ }
526
+ },
527
+ subscribe: (cb) => {
528
+ listeners.add(cb);
529
+ return () => {
530
+ listeners.delete(cb);
531
+ };
532
+ }
533
+ };
534
+ }
467
535
  function parseLocation(routes, loc) {
468
536
  const path = loc.pathname || "/";
469
537
  const query = {};
@@ -532,6 +600,8 @@ function mount(app, target, options = {}) {
532
600
  stateStylesEl = null;
533
601
  ensureMotionStyles(app);
534
602
  const slotValues = app.live;
603
+ const router = options.router === "memory" ? memoryRouter(options.initialPath) : historyRouter();
604
+ let routerUnsub;
535
605
  const dispatcher = makeEffectDispatcher(app, makeCapabilityRegistry(app.caps, options.providers), (effect, outcome, value, key) => {
536
606
  handleEffectResult(effect, outcome, value, key);
537
607
  });
@@ -635,19 +705,24 @@ function mount(app, target, options = {}) {
635
705
  render();
636
706
  }
637
707
  function handleEffectResult(effect, outcome, value, key) {
638
- for (const r of app.reducers) if (r.event.kind === "effect" && r.event.effect === effect && r.event.outcome === outcome) applyReducer(r, {
639
- $1: value,
640
- $2: key
641
- });
708
+ let matched = 0;
709
+ for (const r of app.reducers) if (r.event.kind === "effect" && r.event.effect === effect && r.event.outcome === outcome) {
710
+ applyReducer(r, {
711
+ $1: value,
712
+ $2: key
713
+ });
714
+ matched++;
715
+ }
716
+ if (outcome === "err" && matched === 0) reportUnhandledEffectError(effect, value);
642
717
  }
643
718
  function updateRoute(newPath, replace) {
644
- if (replace) history.replaceState(null, "", newPath);
645
- else history.pushState(null, "", newPath);
719
+ if (replace) router.replace(newPath);
720
+ else router.push(newPath);
646
721
  syncRouteFromLocation();
647
722
  }
648
723
  function syncRouteFromLocation() {
649
724
  const oldRoute = slotValues.route;
650
- const newRoute = parseLocation(app.routes, location);
725
+ const newRoute = parseLocation(app.routes, router.read());
651
726
  slotValues.route = newRoute;
652
727
  if (oldRoute && oldRoute.pattern !== newRoute.pattern) {
653
728
  for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.leave(${JSON.stringify(oldRoute.pattern)})`) applyReducer(r, { $route: oldRoute });
@@ -655,17 +730,17 @@ function mount(app, target, options = {}) {
655
730
  for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.enter(${JSON.stringify(newRoute.pattern)})`) applyReducer(r, { $route: newRoute });
656
731
  render();
657
732
  }
658
- registerBuiltinEffects(app, updateRoute, () => slotValues, () => render());
733
+ registerBuiltinEffects(app, updateRoute, () => router.back(), () => slotValues, () => render());
659
734
  lastAppliedThemeName = null;
660
735
  applyThemeDefaults(app);
661
736
  lastAppliedThemeName = app.live?.[app.themeName ?? ""] ?? app.themeName ?? null;
662
737
  if (app.routes && app.routes.length > 0) {
663
- for (const r of app.routes) if ("redirectTo" in r && matchPattern(r.pattern, location.pathname)) {
664
- history.replaceState(null, "", r.redirectTo);
738
+ for (const r of app.routes) if ("redirectTo" in r && matchPattern(r.pattern, router.read().pathname)) {
739
+ router.replace(r.redirectTo);
665
740
  break;
666
741
  }
667
- slotValues.route = parseLocation(app.routes, location);
668
- window.addEventListener("popstate", () => syncRouteFromLocation());
742
+ slotValues.route = parseLocation(app.routes, router.read());
743
+ routerUnsub = router.subscribe(syncRouteFromLocation);
669
744
  }
670
745
  app._rerender = render;
671
746
  app._dispatch = (reducerName, el) => {
@@ -702,6 +777,7 @@ function mount(app, target, options = {}) {
702
777
  for (const h of anonTimers) clearInterval(h);
703
778
  for (const h of namedTimers.values()) clearInterval(h);
704
779
  namedTimers.clear();
780
+ routerUnsub?.();
705
781
  target.replaceChildren();
706
782
  dispatcher.dispose();
707
783
  } };
@@ -787,7 +863,7 @@ function makeEffectDispatcher(app, caps, onResult) {
787
863
  }
788
864
  };
789
865
  }
790
- function registerBuiltinEffects(app, navigate, getLive, rerender) {
866
+ function registerBuiltinEffects(app, navigate, back, getLive, rerender) {
791
867
  const overridable = (cap, fn) => {
792
868
  return async (input, caps) => {
793
869
  const p = caps.provider(cap);
@@ -821,7 +897,7 @@ function registerBuiltinEffects(app, navigate, getLive, rerender) {
821
897
  name: "navigate-back",
822
898
  cap: "nav.back",
823
899
  invoke: overridable("nav.back", async () => {
824
- history.back();
900
+ back();
825
901
  return {
826
902
  kind: "ok",
827
903
  value: null
@@ -922,6 +998,19 @@ function reportPanic(where, e) {
922
998
  const { message } = panicInfo(e);
923
999
  console.error(`[kumiki] ${isPanic(e) ? "panic" : "error"} in ${where}: ${message}`);
924
1000
  }
1001
+ /**
1002
+ * Surface an effect `err` result that no `.err` reducer consumes (#37). A failed
1003
+ * capability must never fail silently — the storage-unavailable case (sandbox /
1004
+ * private mode) otherwise looks like the app does nothing. Reported via
1005
+ * console.error so the verification tiers (smoke / runScenario, which patch
1006
+ * console.error) flag it, consistent with the v0.3 panic model. Production noise
1007
+ * is the app's own choice: wire an `.err` reducer to handle (or deliberately
1008
+ * ignore) the error.
1009
+ */
1010
+ function reportUnhandledEffectError(effect, value) {
1011
+ const message = value && typeof value === "object" && "message" in value ? String(value.message) : String(value);
1012
+ console.error(`[kumiki] effect "${effect}" returned an error with no .err reducer: ${message}`);
1013
+ }
925
1014
  /** A minimal top-level fallback for a render panic with no enclosing boundary. */
926
1015
  function renderPanicFallback(e) {
927
1016
  const { message, location } = panicInfo(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumikijs/runtime",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Kumiki DOM runtime — mounts compiled Kumiki apps, dispatches effects, manages the signal graph.",
6
6
  "license": "Apache-2.0",