@hypen-space/core 0.4.81 → 0.4.83

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/src/app.ts CHANGED
@@ -140,7 +140,7 @@ export interface ErrorContext<T> {
140
140
  /** The action name if error occurred in an action handler */
141
141
  actionName?: string;
142
142
  /** The lifecycle phase if error occurred in a lifecycle handler */
143
- lifecycle?: "created" | "destroyed" | "disconnect" | "reconnect" | "expire";
143
+ lifecycle?: "created" | "activated" | "deactivated" | "destroyed" | "disconnect" | "reconnect" | "expire";
144
144
  }
145
145
 
146
146
  /**
@@ -181,6 +181,20 @@ export interface HypenModuleDefinition<T = unknown> {
181
181
  stateStore?: StateStore<T>;
182
182
  handlers: {
183
183
  onCreated?: LifecycleHandler<T>;
184
+ /**
185
+ * Called every time the module becomes the active route target.
186
+ * Runs after `onCreated` on first mount, and again on each re-mount
187
+ * from the ManagedRouter's persistence cache. Use for data refresh,
188
+ * subscription (re)connects, or any "screen became visible" work.
189
+ */
190
+ onActivated?: LifecycleHandler<T>;
191
+ /**
192
+ * Called every time the module stops being the active route target.
193
+ * Runs before the module is cached for persistence OR before
194
+ * `onDestroyed` if the module is being torn down. Use for pausing
195
+ * timers, tearing down ephemeral subscriptions, etc.
196
+ */
197
+ onDeactivated?: LifecycleHandler<T>;
184
198
  onAction: Map<string, ActionHandler<T, any>>;
185
199
  onDestroyed?: LifecycleHandler<T>;
186
200
  /** Called when client disconnects (session persists for TTL) */
@@ -206,6 +220,8 @@ export class HypenAppBuilder<T> {
206
220
  private initialState: T;
207
221
  private options: { persist?: boolean; version?: number; name?: string };
208
222
  private createdHandler?: LifecycleHandler<T>;
223
+ private activatedHandler?: LifecycleHandler<T>;
224
+ private deactivatedHandler?: LifecycleHandler<T>;
209
225
  private actionHandlers: Map<string, ActionHandler<T, any>> = new Map();
210
226
  private destroyedHandler?: LifecycleHandler<T>;
211
227
  private disconnectHandler?: DisconnectHandler<T>;
@@ -257,6 +273,47 @@ export class HypenAppBuilder<T> {
257
273
  return this;
258
274
  }
259
275
 
276
+ /**
277
+ * Register a handler that runs every time the module becomes the active
278
+ * route target.
279
+ *
280
+ * Unlike `onCreated`, which only runs once per module instance, `onActivated`
281
+ * runs on **every** mount — the first one (right after `onCreated`) and
282
+ * every subsequent re-entry when a cached module is restored by
283
+ * `ManagedRouter`. Use this hook for data refresh, (re)connecting
284
+ * subscriptions, starting timers, and any "screen became visible" work
285
+ * that should happen on every navigation to this route.
286
+ *
287
+ * @example
288
+ * ```typescript
289
+ * app
290
+ * .defineState({ items: [], loading: false })
291
+ * .onActivated(async (state) => {
292
+ * state.loading = true;
293
+ * state.items = await fetchItems();
294
+ * state.loading = false;
295
+ * });
296
+ * ```
297
+ */
298
+ onActivated(fn: LifecycleHandler<T>): this {
299
+ this.activatedHandler = fn;
300
+ return this;
301
+ }
302
+
303
+ /**
304
+ * Register a handler that runs every time the module stops being the
305
+ * active route target.
306
+ *
307
+ * Runs before the module is cached for persistence (when the user
308
+ * navigates to another route) OR before `onDestroyed` if the module is
309
+ * being torn down. Use this for pausing timers, unsubscribing from
310
+ * ephemeral streams, and any "screen became hidden" cleanup.
311
+ */
312
+ onDeactivated(fn: LifecycleHandler<T>): this {
313
+ this.deactivatedHandler = fn;
314
+ return this;
315
+ }
316
+
260
317
  /**
261
318
  * Register a handler for module destruction
262
319
  */
@@ -453,6 +510,8 @@ export class HypenAppBuilder<T> {
453
510
  stateStore: this._stateStore,
454
511
  handlers: {
455
512
  onCreated: this.createdHandler,
513
+ onActivated: this.activatedHandler,
514
+ onDeactivated: this.deactivatedHandler,
456
515
  onAction: this.actionHandlers,
457
516
  onDestroyed: this.destroyedHandler,
458
517
  onDisconnect: this.disconnectHandler,
@@ -582,6 +641,13 @@ export class HypenModuleInstance<T extends object = any> {
582
641
  private definition: HypenModuleDefinition<T>;
583
642
  private state: T;
584
643
  private isDestroyed = false;
644
+ /**
645
+ * True when the module is currently the active route target (i.e.
646
+ * `onActivated` has fired more recently than `onDeactivated`).
647
+ * Used to make `activate()` / `deactivate()` idempotent so the
648
+ * `ManagedRouter` can call them safely regardless of state.
649
+ */
650
+ private isActive = false;
585
651
  private router: HypenRouter | null;
586
652
  private globalContext?: HypenGlobalContext;
587
653
  private stateChangeCallbacks: Array<() => void> = [];
@@ -718,6 +784,63 @@ export class HypenModuleInstance<T extends object = any> {
718
784
  await this._readyPromise;
719
785
  }
720
786
 
787
+ /**
788
+ * Mark the module as the active route target and fire `onActivated`.
789
+ *
790
+ * Idempotent: calling `activate()` on an already-active module is a
791
+ * no-op. Internally awaits `waitForReady()` so that `onCreated` always
792
+ * runs to completion before `onActivated` fires, regardless of who
793
+ * calls this method and when.
794
+ *
795
+ * Called by `ManagedRouter` on every route mount — both fresh
796
+ * constructions and re-mounts from the persistence cache.
797
+ */
798
+ async activate(): Promise<void> {
799
+ if (this.isDestroyed || this.isActive) return;
800
+ // Ensure onCreated finishes before onActivated — critical on fresh
801
+ // mounts where the constructor kicks off onCreated asynchronously.
802
+ await this._readyPromise;
803
+ if (this.isDestroyed || this.isActive) return;
804
+ this.isActive = true;
805
+ if (this.definition.handlers.onActivated) {
806
+ const context = this.globalContext ? this.createGlobalContextAPI() : undefined;
807
+ try {
808
+ await this.definition.handlers.onActivated(this.state, context);
809
+ } catch (e) {
810
+ const error = e instanceof HypenError ? e : new ActionError("onActivated", e);
811
+ const shouldRethrow = await this.handleError(error, { lifecycle: "activated" });
812
+ if (shouldRethrow) {
813
+ throw error;
814
+ }
815
+ }
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Mark the module as no longer the active route target and fire
821
+ * `onDeactivated`.
822
+ *
823
+ * Idempotent: calling `deactivate()` on an inactive module is a no-op.
824
+ * Called by `ManagedRouter` before persisting a module for later reuse
825
+ * OR before destroying it.
826
+ */
827
+ async deactivate(): Promise<void> {
828
+ if (this.isDestroyed || !this.isActive) return;
829
+ this.isActive = false;
830
+ if (this.definition.handlers.onDeactivated) {
831
+ const context = this.globalContext ? this.createGlobalContextAPI() : undefined;
832
+ try {
833
+ await this.definition.handlers.onDeactivated(this.state, context);
834
+ } catch (e) {
835
+ const error = e instanceof HypenError ? e : new ActionError("onDeactivated", e);
836
+ const shouldRethrow = await this.handleError(error, { lifecycle: "deactivated" });
837
+ if (shouldRethrow) {
838
+ throw error;
839
+ }
840
+ }
841
+ }
842
+ }
843
+
721
844
  /**
722
845
  * Create the global context API for this module
723
846
  */
@@ -963,6 +1086,13 @@ export class HypenModuleInstance<T extends object = any> {
963
1086
  async destroy(): Promise<void> {
964
1087
  if (this.isDestroyed) return;
965
1088
 
1089
+ // If this module is still marked as active (e.g. destroy() was called
1090
+ // without a preceding deactivate()), fire onDeactivated first so the
1091
+ // lifecycle order is always: ...onActivated → onDeactivated → onDestroyed.
1092
+ if (this.isActive) {
1093
+ await this.deactivate();
1094
+ }
1095
+
966
1096
  // Flush any pending persistence writes before shutdown
967
1097
  if (this.currentPersistKey && this.stateStore) {
968
1098
  clearTimeout(this.persistDebounceTimer);
@@ -227,6 +227,61 @@ export abstract class BaseEngine {
227
227
  return action;
228
228
  }
229
229
 
230
+ /**
231
+ * Parse a Hypen DSL source and return every `Router { Route ... }`
232
+ * block found in it. The SDK uses this to auto-wire a ManagedRouter
233
+ * against the template without the user having to repeat the route
234
+ * table in code.
235
+ *
236
+ * `moduleScope` is the enclosing `module X { ... }` scope (lowercased)
237
+ * — `null` for the document root. `elementNames` is BFS-ordered so
238
+ * the SDK can pick the first name that matches a registered
239
+ * `HypenApp` module (wrappers like Column / Row come before the
240
+ * real route component).
241
+ */
242
+ discoverRouters(source: string): Array<{
243
+ moduleScope: string | null;
244
+ routes: Array<{ path: string; elementNames: string[] }>;
245
+ }> {
246
+ const engine = this.ensureInitialized();
247
+ const raw = (engine as any).discoverRouters?.(source);
248
+ if (!raw) return [];
249
+ // wasm-bindgen returns Maps; normalize to plain objects for
250
+ // ergonomic downstream consumption. The nested structures were
251
+ // encoded with serde_wasm_bindgen so plain objects is the right
252
+ // post-decode shape for most consumers.
253
+ const mapToObj = (v: unknown): any => {
254
+ if (v instanceof Map) {
255
+ const obj: Record<string, unknown> = {};
256
+ for (const [k, val] of v.entries()) obj[String(k)] = mapToObj(val);
257
+ return obj;
258
+ }
259
+ if (Array.isArray(v)) return v.map(mapToObj);
260
+ if (v && typeof v === "object") {
261
+ const obj: Record<string, unknown> = {};
262
+ for (const [k, val] of Object.entries(v)) obj[k] = mapToObj(val);
263
+ return obj;
264
+ }
265
+ return v;
266
+ };
267
+ const normalized = mapToObj(raw) as Array<{
268
+ module_scope: string | null;
269
+ moduleScope?: string | null;
270
+ routes: Array<{
271
+ path: string;
272
+ element_names?: string[];
273
+ elementNames?: string[];
274
+ }>;
275
+ }>;
276
+ return normalized.map((r) => ({
277
+ moduleScope: r.moduleScope ?? r.module_scope ?? null,
278
+ routes: r.routes.map((route) => ({
279
+ path: route.path,
280
+ elementNames: route.elementNames ?? route.element_names ?? [],
281
+ })),
282
+ }));
283
+ }
284
+
230
285
  /**
231
286
  * Initialize the primary module.
232
287
  */
package/src/index.ts CHANGED
@@ -132,6 +132,13 @@ export type {
132
132
  StateObserverOptions,
133
133
  } from "./state.js";
134
134
 
135
+ // Portable DI seam: `@hypen-space/server` and `@hypen-space/web-engine`
136
+ // call `setPortableImpl` at their module-init time to route core's
137
+ // diff / matchPath / path-ops / URL helpers through the Rust engine's
138
+ // canonical implementations. Standalone core use gets TS fallbacks.
139
+ export { setPortableImpl, portable } from "./portable.js";
140
+ export type { PortableImpl } from "./portable.js";
141
+
135
142
  // ============================================================================
136
143
  // RENDERER ABSTRACTION
137
144
  // ============================================================================
@@ -150,6 +157,12 @@ export type {
150
157
  RouteChangeCallback,
151
158
  } from "./router.js";
152
159
 
160
+ // Route-based module lifecycle orchestrator. Wraps HypenRouter and
161
+ // mounts/unmounts modules as the route changes, with optional per-module
162
+ // persistence to avoid tearing down state on every navigation.
163
+ export { ManagedRouter } from "./managed-router.js";
164
+ export type { RouteDefinition } from "./managed-router.js";
165
+
153
166
  // ============================================================================
154
167
  // EVENTS
155
168
  // ============================================================================