@hypen-space/core 0.4.82 → 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/README.md CHANGED
@@ -122,6 +122,8 @@ interface UserState {
122
122
  loading: boolean;
123
123
  }
124
124
 
125
+ interface LoadUserPayload { id: string; }
126
+
125
127
  const userModule = app
126
128
  .defineState<UserState>({ user: null, loading: false })
127
129
 
@@ -131,15 +133,16 @@ const userModule = app
131
133
  // State changes are auto-synced via Proxy
132
134
  })
133
135
 
134
- // Action handler: receives context object { action, state, next, context }
135
- .onAction("loadUser", async ({ state, action, next, context }) => {
136
- const userId = action.payload?.id;
137
- state.user = await fetchUser(userId);
136
+ // Typed action handler: declare the payload type as a generic so
137
+ // `action.payload` is statically typed instead of `unknown`.
138
+ .onAction<LoadUserPayload>("loadUser", async ({ state, action, context }) => {
139
+ state.user = await fetchUser(action.payload.id);
138
140
  state.loading = false;
139
141
  // State changes are auto-synced via Proxy
140
142
  // context.router is available for programmatic navigation
141
143
  })
142
144
 
145
+ // Actions without a payload: omit the generic.
143
146
  .onAction("logout", ({ state }) => {
144
147
  state.user = null;
145
148
  })
@@ -153,6 +156,9 @@ const userModule = app
153
156
  ```
154
157
 
155
158
  State mutations are automatically tracked via Proxy and synced to the engine.
159
+ Action payloads default to `unknown` — always pass a payload type to
160
+ `.onAction<P>(...)` when the action carries data so you get end-to-end
161
+ type safety instead of manual casting.
156
162
 
157
163
  ## State
158
164
 
package/dist/app.d.ts CHANGED
@@ -112,7 +112,7 @@ export interface ErrorContext<T> {
112
112
  /** The action name if error occurred in an action handler */
113
113
  actionName?: string;
114
114
  /** The lifecycle phase if error occurred in a lifecycle handler */
115
- lifecycle?: "created" | "destroyed" | "disconnect" | "reconnect" | "expire";
115
+ lifecycle?: "created" | "activated" | "deactivated" | "destroyed" | "disconnect" | "reconnect" | "expire";
116
116
  }
117
117
  /**
118
118
  * Error handler return type - controls error propagation
@@ -155,6 +155,20 @@ export interface HypenModuleDefinition<T = unknown> {
155
155
  stateStore?: StateStore<T>;
156
156
  handlers: {
157
157
  onCreated?: LifecycleHandler<T>;
158
+ /**
159
+ * Called every time the module becomes the active route target.
160
+ * Runs after `onCreated` on first mount, and again on each re-mount
161
+ * from the ManagedRouter's persistence cache. Use for data refresh,
162
+ * subscription (re)connects, or any "screen became visible" work.
163
+ */
164
+ onActivated?: LifecycleHandler<T>;
165
+ /**
166
+ * Called every time the module stops being the active route target.
167
+ * Runs before the module is cached for persistence OR before
168
+ * `onDestroyed` if the module is being torn down. Use for pausing
169
+ * timers, tearing down ephemeral subscriptions, etc.
170
+ */
171
+ onDeactivated?: LifecycleHandler<T>;
158
172
  onAction: Map<string, ActionHandler<T, any>>;
159
173
  onDestroyed?: LifecycleHandler<T>;
160
174
  /** Called when client disconnects (session persists for TTL) */
@@ -178,6 +192,8 @@ export declare class HypenAppBuilder<T> {
178
192
  private initialState;
179
193
  private options;
180
194
  private createdHandler?;
195
+ private activatedHandler?;
196
+ private deactivatedHandler?;
181
197
  private actionHandlers;
182
198
  private destroyedHandler?;
183
199
  private disconnectHandler?;
@@ -215,6 +231,39 @@ export declare class HypenAppBuilder<T> {
215
231
  * ```
216
232
  */
217
233
  onAction<P = unknown>(name: string, fn: ActionHandler<T, P>): this;
234
+ /**
235
+ * Register a handler that runs every time the module becomes the active
236
+ * route target.
237
+ *
238
+ * Unlike `onCreated`, which only runs once per module instance, `onActivated`
239
+ * runs on **every** mount — the first one (right after `onCreated`) and
240
+ * every subsequent re-entry when a cached module is restored by
241
+ * `ManagedRouter`. Use this hook for data refresh, (re)connecting
242
+ * subscriptions, starting timers, and any "screen became visible" work
243
+ * that should happen on every navigation to this route.
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * app
248
+ * .defineState({ items: [], loading: false })
249
+ * .onActivated(async (state) => {
250
+ * state.loading = true;
251
+ * state.items = await fetchItems();
252
+ * state.loading = false;
253
+ * });
254
+ * ```
255
+ */
256
+ onActivated(fn: LifecycleHandler<T>): this;
257
+ /**
258
+ * Register a handler that runs every time the module stops being the
259
+ * active route target.
260
+ *
261
+ * Runs before the module is cached for persistence (when the user
262
+ * navigates to another route) OR before `onDestroyed` if the module is
263
+ * being torn down. Use this for pausing timers, unsubscribing from
264
+ * ephemeral streams, and any "screen became hidden" cleanup.
265
+ */
266
+ onDeactivated(fn: LifecycleHandler<T>): this;
218
267
  /**
219
268
  * Register a handler for module destruction
220
269
  */
@@ -434,6 +483,13 @@ export declare class HypenModuleInstance<T extends object = any> {
434
483
  private definition;
435
484
  private state;
436
485
  private isDestroyed;
486
+ /**
487
+ * True when the module is currently the active route target (i.e.
488
+ * `onActivated` has fired more recently than `onDeactivated`).
489
+ * Used to make `activate()` / `deactivate()` idempotent so the
490
+ * `ManagedRouter` can call them safely regardless of state.
491
+ */
492
+ private isActive;
437
493
  private router;
438
494
  private globalContext?;
439
495
  private stateChangeCallbacks;
@@ -456,6 +512,27 @@ export declare class HypenModuleInstance<T extends object = any> {
456
512
  * Call this before renderSource to ensure state is fully initialized.
457
513
  */
458
514
  waitForReady(): Promise<void>;
515
+ /**
516
+ * Mark the module as the active route target and fire `onActivated`.
517
+ *
518
+ * Idempotent: calling `activate()` on an already-active module is a
519
+ * no-op. Internally awaits `waitForReady()` so that `onCreated` always
520
+ * runs to completion before `onActivated` fires, regardless of who
521
+ * calls this method and when.
522
+ *
523
+ * Called by `ManagedRouter` on every route mount — both fresh
524
+ * constructions and re-mounts from the persistence cache.
525
+ */
526
+ activate(): Promise<void>;
527
+ /**
528
+ * Mark the module as no longer the active route target and fire
529
+ * `onDeactivated`.
530
+ *
531
+ * Idempotent: calling `deactivate()` on an inactive module is a no-op.
532
+ * Called by `ManagedRouter` before persisting a module for later reuse
533
+ * OR before destroying it.
534
+ */
535
+ deactivate(): Promise<void>;
459
536
  /**
460
537
  * Create the global context API for this module
461
538
  */
package/dist/app.js CHANGED
@@ -262,6 +262,53 @@ class DataSourceManager {
262
262
  }
263
263
  }
264
264
 
265
+ // src/portable.ts
266
+ function notInstalled(name) {
267
+ throw new Error(`[@hypen-space/core] Portable helper "${name}" called before the engine was installed. ` + `Import @hypen-space/server, @hypen-space/web-engine, or call setPortableImpl() before use.`);
268
+ }
269
+ var current = {
270
+ diffState: () => notInstalled("diffState"),
271
+ matchPath: () => notInstalled("matchPath"),
272
+ pathGet: () => notInstalled("pathGet"),
273
+ pathHas: () => notInstalled("pathHas"),
274
+ pathSet: () => notInstalled("pathSet"),
275
+ pathDelete: () => notInstalled("pathDelete"),
276
+ encodeUriComponent: () => notInstalled("encodeUriComponent"),
277
+ decodeUriComponent: () => notInstalled("decodeUriComponent"),
278
+ parseQuery: () => notInstalled("parseQuery"),
279
+ buildUrl: () => notInstalled("buildUrl")
280
+ };
281
+ function setPortableImpl(impl) {
282
+ if (impl === null) {
283
+ current = {
284
+ diffState: () => notInstalled("diffState"),
285
+ matchPath: () => notInstalled("matchPath"),
286
+ pathGet: () => notInstalled("pathGet"),
287
+ pathHas: () => notInstalled("pathHas"),
288
+ pathSet: () => notInstalled("pathSet"),
289
+ pathDelete: () => notInstalled("pathDelete"),
290
+ encodeUriComponent: () => notInstalled("encodeUriComponent"),
291
+ decodeUriComponent: () => notInstalled("decodeUriComponent"),
292
+ parseQuery: () => notInstalled("parseQuery"),
293
+ buildUrl: () => notInstalled("buildUrl")
294
+ };
295
+ return;
296
+ }
297
+ current = impl;
298
+ }
299
+ var portable = {
300
+ diffState: (o, n, b) => current.diffState(o, n, b),
301
+ matchPath: (p, path) => current.matchPath(p, path),
302
+ pathGet: (v, p) => current.pathGet(v, p),
303
+ pathHas: (v, p) => current.pathHas(v, p),
304
+ pathSet: (v, p, nv) => current.pathSet(v, p, nv),
305
+ pathDelete: (v, p) => current.pathDelete(v, p),
306
+ encodeUriComponent: (s) => current.encodeUriComponent(s),
307
+ decodeUriComponent: (s) => current.decodeUriComponent(s),
308
+ parseQuery: (f) => current.parseQuery(f),
309
+ buildUrl: (p, q) => current.buildUrl(p, q)
310
+ };
311
+
265
312
  // src/state.ts
266
313
  var IS_PROXY = Symbol.for("hypen.isProxy");
267
314
  var RAW_TARGET = Symbol.for("hypen.rawTarget");
@@ -321,51 +368,7 @@ function deepClone(obj) {
321
368
  return cloneInternal(obj);
322
369
  }
323
370
  function diffState(oldState, newState, basePath = "") {
324
- const paths = [];
325
- const newValues = {};
326
- function diff(oldVal, newVal, path) {
327
- if (oldVal === newVal)
328
- return;
329
- if (typeof oldVal !== "object" || typeof newVal !== "object" || oldVal === null || newVal === null) {
330
- if (oldVal !== newVal) {
331
- paths.push(path);
332
- newValues[path] = newVal;
333
- }
334
- return;
335
- }
336
- if (Array.isArray(oldVal) || Array.isArray(newVal)) {
337
- if (!Array.isArray(oldVal) || !Array.isArray(newVal) || oldVal.length !== newVal.length) {
338
- paths.push(path);
339
- newValues[path] = newVal;
340
- return;
341
- }
342
- for (let i = 0;i < newVal.length; i++) {
343
- const itemPath = path ? `${path}.${i}` : `${i}`;
344
- diff(oldVal[i], newVal[i], itemPath);
345
- }
346
- return;
347
- }
348
- const oldKeys = new Set(Object.keys(oldVal));
349
- const newKeys = new Set(Object.keys(newVal));
350
- for (const key of newKeys) {
351
- const propPath = path ? `${path}.${key}` : key;
352
- if (!oldKeys.has(key)) {
353
- paths.push(propPath);
354
- newValues[propPath] = newVal[key];
355
- } else {
356
- diff(oldVal[key], newVal[key], propPath);
357
- }
358
- }
359
- for (const key of oldKeys) {
360
- if (!newKeys.has(key)) {
361
- const propPath = path ? `${path}.${key}` : key;
362
- paths.push(propPath);
363
- newValues[propPath] = undefined;
364
- }
365
- }
366
- }
367
- diff(oldState, newState, basePath);
368
- return { paths, newValues };
371
+ return portable.diffState(oldState, newState, basePath);
369
372
  }
370
373
  function createObservableState(initialState, options) {
371
374
  const opts = options || { onChange: () => {} };
@@ -714,6 +717,8 @@ class HypenAppBuilder {
714
717
  initialState;
715
718
  options;
716
719
  createdHandler;
720
+ activatedHandler;
721
+ deactivatedHandler;
717
722
  actionHandlers = new Map;
718
723
  destroyedHandler;
719
724
  disconnectHandler;
@@ -737,6 +742,14 @@ class HypenAppBuilder {
737
742
  this.actionHandlers.set(name, fn);
738
743
  return this;
739
744
  }
745
+ onActivated(fn) {
746
+ this.activatedHandler = fn;
747
+ return this;
748
+ }
749
+ onDeactivated(fn) {
750
+ this.deactivatedHandler = fn;
751
+ return this;
752
+ }
740
753
  onDestroyed(fn) {
741
754
  this.destroyedHandler = fn;
742
755
  return this;
@@ -788,6 +801,8 @@ class HypenAppBuilder {
788
801
  stateStore: this._stateStore,
789
802
  handlers: {
790
803
  onCreated: this.createdHandler,
804
+ onActivated: this.activatedHandler,
805
+ onDeactivated: this.deactivatedHandler,
791
806
  onAction: this.actionHandlers,
792
807
  onDestroyed: this.destroyedHandler,
793
808
  onDisconnect: this.disconnectHandler,
@@ -845,6 +860,7 @@ class HypenModuleInstance {
845
860
  definition;
846
861
  state;
847
862
  isDestroyed = false;
863
+ isActive = false;
848
864
  router;
849
865
  globalContext;
850
866
  stateChangeCallbacks = [];
@@ -924,6 +940,43 @@ class HypenModuleInstance {
924
940
  async waitForReady() {
925
941
  await this._readyPromise;
926
942
  }
943
+ async activate() {
944
+ if (this.isDestroyed || this.isActive)
945
+ return;
946
+ await this._readyPromise;
947
+ if (this.isDestroyed || this.isActive)
948
+ return;
949
+ this.isActive = true;
950
+ if (this.definition.handlers.onActivated) {
951
+ const context = this.globalContext ? this.createGlobalContextAPI() : undefined;
952
+ try {
953
+ await this.definition.handlers.onActivated(this.state, context);
954
+ } catch (e) {
955
+ const error = e instanceof HypenError ? e : new ActionError("onActivated", e);
956
+ const shouldRethrow = await this.handleError(error, { lifecycle: "activated" });
957
+ if (shouldRethrow) {
958
+ throw error;
959
+ }
960
+ }
961
+ }
962
+ }
963
+ async deactivate() {
964
+ if (this.isDestroyed || !this.isActive)
965
+ return;
966
+ this.isActive = false;
967
+ if (this.definition.handlers.onDeactivated) {
968
+ const context = this.globalContext ? this.createGlobalContextAPI() : undefined;
969
+ try {
970
+ await this.definition.handlers.onDeactivated(this.state, context);
971
+ } catch (e) {
972
+ const error = e instanceof HypenError ? e : new ActionError("onDeactivated", e);
973
+ const shouldRethrow = await this.handleError(error, { lifecycle: "deactivated" });
974
+ if (shouldRethrow) {
975
+ throw error;
976
+ }
977
+ }
978
+ }
979
+ }
927
980
  createGlobalContextAPI() {
928
981
  if (!this.globalContext) {
929
982
  throw new Error("Global context not available");
@@ -1072,6 +1125,9 @@ class HypenModuleInstance {
1072
1125
  async destroy() {
1073
1126
  if (this.isDestroyed)
1074
1127
  return;
1128
+ if (this.isActive) {
1129
+ await this.deactivate();
1130
+ }
1075
1131
  if (this.currentPersistKey && this.stateStore) {
1076
1132
  clearTimeout(this.persistDebounceTimer);
1077
1133
  const snapshot = getStateSnapshot(this.state);
@@ -1116,4 +1172,4 @@ export {
1116
1172
  HypenApp
1117
1173
  };
1118
1174
 
1119
- //# debugId=B8AA1FCB3E096F2264756E2164756E21
1175
+ //# debugId=94DE9279313C0D1764756E2164756E21