@noego/wood 0.5.0 → 0.6.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.
Files changed (142) hide show
  1. package/README.md +31 -0
  2. package/dist/client/index.cjs +18 -11
  3. package/dist/client/index.cjs.map +1 -1
  4. package/dist/client/index.d.cts +21 -1
  5. package/dist/client/index.d.ts +21 -1
  6. package/dist/client/index.js +17 -11
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/codegen/renderer_app_generator.cjs +2 -9
  9. package/dist/codegen/renderer_app_generator.cjs.map +1 -1
  10. package/dist/codegen/renderer_app_generator.js +2 -9
  11. package/dist/codegen/renderer_app_generator.js.map +1 -1
  12. package/dist/controller/cleanup.cjs +39 -0
  13. package/dist/controller/cleanup.cjs.map +1 -0
  14. package/dist/controller/cleanup.d.cts +4 -0
  15. package/dist/controller/cleanup.d.ts +4 -0
  16. package/dist/controller/cleanup.js +15 -0
  17. package/dist/controller/cleanup.js.map +1 -0
  18. package/dist/controller/controller_lifecycle.cjs +26 -2
  19. package/dist/controller/controller_lifecycle.cjs.map +1 -1
  20. package/dist/controller/controller_lifecycle.d.cts +3 -1
  21. package/dist/controller/controller_lifecycle.d.ts +3 -1
  22. package/dist/controller/controller_lifecycle.js +24 -1
  23. package/dist/controller/controller_lifecycle.js.map +1 -1
  24. package/dist/frontend/frontend_lifecycle.cjs +5 -7
  25. package/dist/frontend/frontend_lifecycle.cjs.map +1 -1
  26. package/dist/frontend/frontend_lifecycle.d.cts +1 -14
  27. package/dist/frontend/frontend_lifecycle.d.ts +1 -14
  28. package/dist/frontend/frontend_lifecycle.js +5 -7
  29. package/dist/frontend/frontend_lifecycle.js.map +1 -1
  30. package/dist/frontend/frontend_surface.cjs +130 -104
  31. package/dist/frontend/frontend_surface.cjs.map +1 -1
  32. package/dist/frontend/frontend_surface.js +131 -105
  33. package/dist/frontend/frontend_surface.js.map +1 -1
  34. package/dist/frontend/work_registry.cjs +29 -4
  35. package/dist/frontend/work_registry.cjs.map +1 -1
  36. package/dist/frontend/work_registry.d.cts +2 -0
  37. package/dist/frontend/work_registry.d.ts +2 -0
  38. package/dist/frontend/work_registry.js +29 -4
  39. package/dist/frontend/work_registry.js.map +1 -1
  40. package/dist/index.d.cts +2 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/ipc/index.cjs +6 -2
  43. package/dist/ipc/index.cjs.map +1 -1
  44. package/dist/ipc/index.d.cts +9 -0
  45. package/dist/ipc/index.d.ts +9 -0
  46. package/dist/ipc/index.js +6 -2
  47. package/dist/ipc/index.js.map +1 -1
  48. package/dist/main/index.cjs +53 -35
  49. package/dist/main/index.cjs.map +1 -1
  50. package/dist/main/index.d.cts +11 -2
  51. package/dist/main/index.d.ts +11 -2
  52. package/dist/main/index.js +56 -40
  53. package/dist/main/index.js.map +1 -1
  54. package/dist/main/startup_failure_handler.cjs +67 -0
  55. package/dist/main/startup_failure_handler.cjs.map +1 -0
  56. package/dist/main/startup_failure_handler.d.cts +16 -0
  57. package/dist/main/startup_failure_handler.d.ts +16 -0
  58. package/dist/main/startup_failure_handler.js +43 -0
  59. package/dist/main/startup_failure_handler.js.map +1 -0
  60. package/dist/router/index.d.cts +3 -0
  61. package/dist/router/index.d.ts +3 -0
  62. package/dist/router/wood_router.cjs +204 -123
  63. package/dist/router/wood_router.cjs.map +1 -1
  64. package/dist/router/wood_router.d.cts +20 -0
  65. package/dist/router/wood_router.d.ts +20 -0
  66. package/dist/router/wood_router.js +206 -125
  67. package/dist/router/wood_router.js.map +1 -1
  68. package/dist/runtime/index.cjs +13 -0
  69. package/dist/runtime/index.cjs.map +1 -1
  70. package/dist/runtime/index.d.cts +8 -4
  71. package/dist/runtime/index.d.ts +8 -4
  72. package/dist/runtime/index.js +8 -0
  73. package/dist/runtime/index.js.map +1 -1
  74. package/dist/runtime/renderer_config_extension.cjs +1 -2
  75. package/dist/runtime/renderer_config_extension.cjs.map +1 -1
  76. package/dist/runtime/renderer_config_extension.d.cts +1 -1
  77. package/dist/runtime/renderer_config_extension.d.ts +1 -1
  78. package/dist/runtime/renderer_config_extension.js +1 -2
  79. package/dist/runtime/renderer_config_extension.js.map +1 -1
  80. package/dist/runtime/renderer_log_dispatcher.cjs +2 -0
  81. package/dist/runtime/renderer_log_dispatcher.cjs.map +1 -1
  82. package/dist/runtime/renderer_log_dispatcher.d.cts +1 -1
  83. package/dist/runtime/renderer_log_dispatcher.d.ts +1 -1
  84. package/dist/runtime/renderer_log_dispatcher.js +1 -0
  85. package/dist/runtime/renderer_log_dispatcher.js.map +1 -1
  86. package/dist/runtime/runtime.cjs +107 -57
  87. package/dist/runtime/runtime.cjs.map +1 -1
  88. package/dist/runtime/runtime.d.cts +12 -0
  89. package/dist/runtime/runtime.d.ts +12 -0
  90. package/dist/runtime/runtime.js +110 -59
  91. package/dist/runtime/runtime.js.map +1 -1
  92. package/dist/runtime/runtime_module.cjs +58 -0
  93. package/dist/runtime/runtime_module.cjs.map +1 -0
  94. package/dist/runtime/runtime_module.d.cts +22 -0
  95. package/dist/runtime/runtime_module.d.ts +22 -0
  96. package/dist/runtime/runtime_module.js +30 -0
  97. package/dist/runtime/runtime_module.js.map +1 -0
  98. package/dist/testing/create_controller_harness.d.cts +3 -0
  99. package/dist/testing/create_controller_harness.d.ts +3 -0
  100. package/dist/testing/create_router_bridge.d.cts +3 -0
  101. package/dist/testing/create_router_bridge.d.ts +3 -0
  102. package/dist/testing/create_test_router.d.cts +3 -0
  103. package/dist/testing/create_test_router.d.ts +3 -0
  104. package/dist/testing/index.cjs +3 -0
  105. package/dist/testing/index.cjs.map +1 -1
  106. package/dist/testing/index.d.cts +6 -1
  107. package/dist/testing/index.d.ts +6 -1
  108. package/dist/testing/index.js +2 -0
  109. package/dist/testing/index.js.map +1 -1
  110. package/dist/testing/registration_only_root.cjs +168 -0
  111. package/dist/testing/registration_only_root.cjs.map +1 -0
  112. package/dist/testing/registration_only_root.d.cts +46 -0
  113. package/dist/testing/registration_only_root.d.ts +46 -0
  114. package/dist/testing/registration_only_root.js +141 -0
  115. package/dist/testing/registration_only_root.js.map +1 -0
  116. package/dist/testing/test_wood_config.cjs +382 -70
  117. package/dist/testing/test_wood_config.cjs.map +1 -1
  118. package/dist/testing/test_wood_config.d.cts +25 -1
  119. package/dist/testing/test_wood_config.d.ts +25 -1
  120. package/dist/testing/test_wood_config.js +388 -71
  121. package/dist/testing/test_wood_config.js.map +1 -1
  122. package/dist/testing/wood_dom_animation.cjs +213 -0
  123. package/dist/testing/wood_dom_animation.cjs.map +1 -0
  124. package/dist/testing/wood_dom_animation.d.cts +89 -0
  125. package/dist/testing/wood_dom_animation.d.ts +89 -0
  126. package/dist/testing/wood_dom_animation.js +185 -0
  127. package/dist/testing/wood_dom_animation.js.map +1 -0
  128. package/dist/testing/wood_env_renderer.cjs +81 -32
  129. package/dist/testing/wood_env_renderer.cjs.map +1 -1
  130. package/dist/testing/wood_env_renderer.d.cts +32 -1
  131. package/dist/testing/wood_env_renderer.d.ts +32 -1
  132. package/dist/testing/wood_env_renderer.js +84 -32
  133. package/dist/testing/wood_env_renderer.js.map +1 -1
  134. package/dist/transport/in_process_router_transport.d.cts +3 -0
  135. package/dist/transport/in_process_router_transport.d.ts +3 -0
  136. package/dist/transport/index.d.cts +3 -0
  137. package/dist/transport/index.d.ts +3 -0
  138. package/package.json +8 -6
  139. package/src/components/NavigationShell.svelte +12 -7
  140. package/src/controller/cleanup.ts +8 -0
  141. package/src/controller/controller_lifecycle.ts +24 -0
  142. package/src/navigation/layout_preservation.ts +45 -0
@@ -2,6 +2,7 @@ async function disposeControllerLifecycle(target) {
2
2
  if (!target || typeof target !== "object" && typeof target !== "function") {
3
3
  return;
4
4
  }
5
+ guardControllerLifecycleOnce(target);
5
6
  const lifecycle = target;
6
7
  if (typeof lifecycle.dispose === "function") {
7
8
  await lifecycle.dispose();
@@ -11,7 +12,29 @@ async function disposeControllerLifecycle(target) {
11
12
  await lifecycle.destroy();
12
13
  }
13
14
  }
15
+ const guardedControllers = /* @__PURE__ */ new WeakSet();
16
+ function guardControllerLifecycleOnce(target) {
17
+ if (!target || typeof target !== "object" && typeof target !== "function") return;
18
+ if (guardedControllers.has(target)) return;
19
+ guardedControllers.add(target);
20
+ const instance = target;
21
+ for (const name of ["dispose", "destroy"]) {
22
+ const original = instance[name];
23
+ if (typeof original !== "function") continue;
24
+ let called = false;
25
+ Object.defineProperty(instance, name, {
26
+ configurable: true,
27
+ writable: true,
28
+ value: function(...args) {
29
+ if (called) return;
30
+ called = true;
31
+ return original.apply(this, args);
32
+ }
33
+ });
34
+ }
35
+ }
14
36
  export {
15
- disposeControllerLifecycle
37
+ disposeControllerLifecycle,
38
+ guardControllerLifecycleOnce
16
39
  };
17
40
  //# sourceMappingURL=controller_lifecycle.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controller/controller_lifecycle.ts"],"sourcesContent":["export interface ControllerLifecycle {\n dispose?(): void | Promise<void>;\n destroy?(): void | Promise<void>;\n}\n\n/**\n * Dispose a controller or controller-like instance if it exposes a lifecycle\n * hook. `dispose` is the preferred Wood lifecycle name; `destroy` is supported\n * for existing page controllers.\n */\nexport async function disposeControllerLifecycle(target: unknown): Promise<void> {\n if (!target || (typeof target !== 'object' && typeof target !== 'function')) {\n return;\n }\n\n const lifecycle = target as ControllerLifecycle;\n if (typeof lifecycle.dispose === 'function') {\n await lifecycle.dispose();\n return;\n }\n if (typeof lifecycle.destroy === 'function') {\n await lifecycle.destroy();\n }\n}\n"],"mappings":"AAUA,eAAsB,2BAA2B,QAAgC;AAC/E,MAAI,CAAC,UAAW,OAAO,WAAW,YAAY,OAAO,WAAW,YAAa;AAC3E;AAAA,EACF;AAEA,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,YAAY,YAAY;AAC3C,UAAM,UAAU,QAAQ;AACxB;AAAA,EACF;AACA,MAAI,OAAO,UAAU,YAAY,YAAY;AAC3C,UAAM,UAAU,QAAQ;AAAA,EAC1B;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/controller/controller_lifecycle.ts"],"sourcesContent":["export interface ControllerLifecycle {\n dispose?(): void | Promise<void>;\n destroy?(): void | Promise<void>;\n}\n\n/**\n * Dispose a controller or controller-like instance if it exposes a lifecycle\n * hook. `dispose` is the preferred Wood lifecycle name; `destroy` is supported\n * for existing page controllers.\n */\nexport async function disposeControllerLifecycle(target: unknown): Promise<void> {\n if (!target || (typeof target !== 'object' && typeof target !== 'function')) {\n return;\n }\n\n guardControllerLifecycleOnce(target);\n const lifecycle = target as ControllerLifecycle;\n if (typeof lifecycle.dispose === 'function') {\n await lifecycle.dispose();\n return;\n }\n if (typeof lifecycle.destroy === 'function') {\n await lifecycle.destroy();\n }\n}\n\nconst guardedControllers = new WeakSet<object>();\n\n/** Share one teardown attempt between the explicit controller owner and IoC. */\nexport function guardControllerLifecycleOnce(target: unknown): void {\n if (!target || (typeof target !== 'object' && typeof target !== 'function')) return;\n if (guardedControllers.has(target as object)) return;\n guardedControllers.add(target as object);\n const instance = target as Record<string, unknown>;\n for (const name of ['dispose', 'destroy'] as const) {\n const original = instance[name];\n if (typeof original !== 'function') continue;\n let called = false;\n Object.defineProperty(instance, name, {\n configurable: true, writable: true,\n value: function (this: unknown, ...args: unknown[]) {\n if (called) return;\n called = true;\n return original.apply(this, args);\n },\n });\n }\n}\n"],"mappings":"AAUA,eAAsB,2BAA2B,QAAgC;AAC/E,MAAI,CAAC,UAAW,OAAO,WAAW,YAAY,OAAO,WAAW,YAAa;AAC3E;AAAA,EACF;AAEA,+BAA6B,MAAM;AACnC,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,YAAY,YAAY;AAC3C,UAAM,UAAU,QAAQ;AACxB;AAAA,EACF;AACA,MAAI,OAAO,UAAU,YAAY,YAAY;AAC3C,UAAM,UAAU,QAAQ;AAAA,EAC1B;AACF;AAEA,MAAM,qBAAqB,oBAAI,QAAgB;AAGxC,SAAS,6BAA6B,QAAuB;AAClE,MAAI,CAAC,UAAW,OAAO,WAAW,YAAY,OAAO,WAAW,WAAa;AAC7E,MAAI,mBAAmB,IAAI,MAAgB,EAAG;AAC9C,qBAAmB,IAAI,MAAgB;AACvC,QAAM,WAAW;AACjB,aAAW,QAAQ,CAAC,WAAW,SAAS,GAAY;AAClD,UAAM,WAAW,SAAS,IAAI;AAC9B,QAAI,OAAO,aAAa,WAAY;AACpC,QAAI,SAAS;AACb,WAAO,eAAe,UAAU,MAAM;AAAA,MACpC,cAAc;AAAA,MAAM,UAAU;AAAA,MAC9B,OAAO,YAA4B,MAAiB;AAClD,YAAI,OAAQ;AACZ,iBAAS;AACT,eAAO,SAAS,MAAM,MAAM,IAAI;AAAA,MAClC;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":[]}
@@ -21,6 +21,7 @@ __export(frontend_lifecycle_exports, {
21
21
  FrontendLifecycleOwner: () => FrontendLifecycleOwner
22
22
  });
23
23
  module.exports = __toCommonJS(frontend_lifecycle_exports);
24
+ var import_cleanup = require("../controller/cleanup.js");
24
25
  var import_work_registry = require("./work_registry.js");
25
26
  class FrontendLifecycleOwner {
26
27
  constructor(registry, owner) {
@@ -56,15 +57,12 @@ class FrontendLifecycleOwner {
56
57
  get isDisposed() {
57
58
  return this.disposed;
58
59
  }
59
- /** Abort owned work and dispose owned resources in reverse order. */
60
- async dispose() {
61
- if (this.disposed) return;
60
+ dispose() {
61
+ if (this.closing) return this.closing;
62
62
  this.disposed = true;
63
63
  this.abortController.abort();
64
- for (const resource of this.resources.reverse()) {
65
- await resource.dispose();
66
- }
67
- this.resources.length = 0;
64
+ const resources = this.resources.splice(0).reverse();
65
+ return this.closing = (0, import_cleanup.cleanupAll)(resources.map((resource) => () => resource.dispose()), "Wood frontend lifecycle cleanup failed");
68
66
  }
69
67
  }
70
68
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/frontend/frontend_lifecycle.ts"],"sourcesContent":["/**\n * Production lifecycle owners for deliberately detached frontend work\n * (18A D-FAS-01 §2.2). Two natural owners exist per environment:\n *\n * page lifecycle — default for PageController work; aborted and\n * disposed when that page ends\n * application lifecycle — explicit for work that intentionally survives\n * page navigation; disposed with the environment\n *\n * PageControllers/client services register their own deliberately detached\n * finite work through their injected lifecycle owner — tests do not repair\n * unowned product code by wrapping it in track() from the outside.\n */\nimport { WorkRegistry, FrontendWorkError } from './work_registry.js';\n\nexport interface FrontendLifecycle {\n readonly signal: AbortSignal;\n\n track<T>(\n work: Promise<T>,\n options: {\n label: string;\n kind?: string;\n },\n ): Promise<T>;\n\n own(resource: { dispose(): void | Promise<void> }): void;\n}\n\nexport class FrontendLifecycleOwner implements FrontendLifecycle {\n private readonly abortController = new AbortController();\n private readonly resources: Array<{ dispose(): void | Promise<void> }> = [];\n private disposed = false;\n\n constructor(\n private readonly registry: WorkRegistry,\n /** Diagnostic owner identity, e.g. `page main.chat_dom` or `application`. */\n readonly owner: string,\n ) {}\n\n get signal(): AbortSignal {\n return this.abortController.signal;\n }\n\n track<T>(work: Promise<T>, options: { label: string; kind?: string }): Promise<T> {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot track \"${options.label}\": lifecycle \"${this.owner}\" is disposed. ` +\n 'Work started after a page/environment ended is a product lifecycle bug.',\n );\n }\n return this.registry.track(work, {\n label: options.label,\n kind: options.kind ?? 'tracked',\n owner: this.owner,\n });\n }\n\n own(resource: { dispose(): void | Promise<void> }): void {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot own a resource: lifecycle \"${this.owner}\" is disposed.`,\n );\n }\n this.resources.push(resource);\n }\n\n get isDisposed(): boolean {\n return this.disposed;\n }\n\n /** Abort owned work and dispose owned resources in reverse order. */\n async dispose(): Promise<void> {\n if (this.disposed) return;\n this.disposed = true;\n this.abortController.abort();\n for (const resource of this.resources.reverse()) {\n await resource.dispose();\n }\n this.resources.length = 0;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAaA,2BAAgD;AAgBzC,MAAM,uBAAoD;AAAA,EAK/D,YACmB,UAER,OACT;AAHiB;AAER;AAPX,SAAiB,kBAAkB,IAAI,gBAAgB;AACvD,SAAiB,YAAwD,CAAC;AAC1E,SAAQ,WAAW;AAAA,EAMhB;AAAA,EAEH,IAAI,SAAsB;AACxB,WAAO,KAAK,gBAAgB;AAAA,EAC9B;AAAA,EAEA,MAAS,MAAkB,SAAuD;AAChF,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,iBAAiB,QAAQ,KAAK,iBAAiB,KAAK,KAAK;AAAA,MAE3D;AAAA,IACF;AACA,WAAO,KAAK,SAAS,MAAM,MAAM;AAAA,MAC/B,OAAO,QAAQ;AAAA,MACf,MAAM,QAAQ,QAAQ;AAAA,MACtB,OAAO,KAAK;AAAA,IACd,CAAC;AAAA,EACH;AAAA,EAEA,IAAI,UAAqD;AACvD,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,qCAAqC,KAAK,KAAK;AAAA,MACjD;AAAA,IACF;AACA,SAAK,UAAU,KAAK,QAAQ;AAAA,EAC9B;AAAA,EAEA,IAAI,aAAsB;AACxB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,MAAM,UAAyB;AAC7B,QAAI,KAAK,SAAU;AACnB,SAAK,WAAW;AAChB,SAAK,gBAAgB,MAAM;AAC3B,eAAW,YAAY,KAAK,UAAU,QAAQ,GAAG;AAC/C,YAAM,SAAS,QAAQ;AAAA,IACzB;AACA,SAAK,UAAU,SAAS;AAAA,EAC1B;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/frontend/frontend_lifecycle.ts"],"sourcesContent":["/**\n * Production lifecycle owners for deliberately detached frontend work\n * (18A D-FAS-01 §2.2). Two natural owners exist per environment:\n *\n * page lifecycle — default for PageController work; aborted and\n * disposed when that page ends\n * application lifecycle — explicit for work that intentionally survives\n * page navigation; disposed with the environment\n *\n * PageControllers/client services register their own deliberately detached\n * finite work through their injected lifecycle owner — tests do not repair\n * unowned product code by wrapping it in track() from the outside.\n */\nimport { cleanupAll } from '../controller/cleanup.js';\nimport { WorkRegistry, FrontendWorkError } from './work_registry.js';\n\nexport interface FrontendLifecycle {\n readonly signal: AbortSignal;\n\n track<T>(\n work: Promise<T>,\n options: {\n label: string;\n kind?: string;\n },\n ): Promise<T>;\n\n own(resource: { dispose(): void | Promise<void> }): void;\n}\n\nexport class FrontendLifecycleOwner implements FrontendLifecycle {\n private readonly abortController = new AbortController();\n private readonly resources: Array<{ dispose(): void | Promise<void> }> = [];\n private disposed = false;\n\n constructor(\n private readonly registry: WorkRegistry,\n /** Diagnostic owner identity, e.g. `page main.chat_dom` or `application`. */\n readonly owner: string,\n ) {}\n\n get signal(): AbortSignal {\n return this.abortController.signal;\n }\n\n track<T>(work: Promise<T>, options: { label: string; kind?: string }): Promise<T> {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot track \"${options.label}\": lifecycle \"${this.owner}\" is disposed. ` +\n 'Work started after a page/environment ended is a product lifecycle bug.',\n );\n }\n return this.registry.track(work, {\n label: options.label,\n kind: options.kind ?? 'tracked',\n owner: this.owner,\n });\n }\n\n own(resource: { dispose(): void | Promise<void> }): void {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot own a resource: lifecycle \"${this.owner}\" is disposed.`,\n );\n }\n this.resources.push(resource);\n }\n\n get isDisposed(): boolean {\n return this.disposed;\n }\n\n /** Abort owned work and dispose owned resources in reverse order. */\n private closing?: Promise<void>;\n dispose(): Promise<void> {\n if (this.closing) return this.closing;\n this.disposed = true;\n this.abortController.abort();\n const resources = this.resources.splice(0).reverse();\n return this.closing = cleanupAll(resources.map(resource => () => resource.dispose()), 'Wood frontend lifecycle cleanup failed');\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAaA,qBAA2B;AAC3B,2BAAgD;AAgBzC,MAAM,uBAAoD;AAAA,EAK/D,YACmB,UAER,OACT;AAHiB;AAER;AAPX,SAAiB,kBAAkB,IAAI,gBAAgB;AACvD,SAAiB,YAAwD,CAAC;AAC1E,SAAQ,WAAW;AAAA,EAMhB;AAAA,EAEH,IAAI,SAAsB;AACxB,WAAO,KAAK,gBAAgB;AAAA,EAC9B;AAAA,EAEA,MAAS,MAAkB,SAAuD;AAChF,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,iBAAiB,QAAQ,KAAK,iBAAiB,KAAK,KAAK;AAAA,MAE3D;AAAA,IACF;AACA,WAAO,KAAK,SAAS,MAAM,MAAM;AAAA,MAC/B,OAAO,QAAQ;AAAA,MACf,MAAM,QAAQ,QAAQ;AAAA,MACtB,OAAO,KAAK;AAAA,IACd,CAAC;AAAA,EACH;AAAA,EAEA,IAAI,UAAqD;AACvD,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,qCAAqC,KAAK,KAAK;AAAA,MACjD;AAAA,IACF;AACA,SAAK,UAAU,KAAK,QAAQ;AAAA,EAC9B;AAAA,EAEA,IAAI,aAAsB;AACxB,WAAO,KAAK;AAAA,EACd;AAAA,EAIA,UAAyB;AACvB,QAAI,KAAK,QAAS,QAAO,KAAK;AAC9B,SAAK,WAAW;AAChB,SAAK,gBAAgB,MAAM;AAC3B,UAAM,YAAY,KAAK,UAAU,OAAO,CAAC,EAAE,QAAQ;AACnD,WAAO,KAAK,cAAU,2BAAW,UAAU,IAAI,cAAY,MAAM,SAAS,QAAQ,CAAC,GAAG,wCAAwC;AAAA,EAChI;AACF;","names":[]}
@@ -1,19 +1,5 @@
1
1
  import { WorkRegistry } from './work_registry.cjs';
2
2
 
3
- /**
4
- * Production lifecycle owners for deliberately detached frontend work
5
- * (18A D-FAS-01 §2.2). Two natural owners exist per environment:
6
- *
7
- * page lifecycle — default for PageController work; aborted and
8
- * disposed when that page ends
9
- * application lifecycle — explicit for work that intentionally survives
10
- * page navigation; disposed with the environment
11
- *
12
- * PageControllers/client services register their own deliberately detached
13
- * finite work through their injected lifecycle owner — tests do not repair
14
- * unowned product code by wrapping it in track() from the outside.
15
- */
16
-
17
3
  interface FrontendLifecycle {
18
4
  readonly signal: AbortSignal;
19
5
  track<T>(work: Promise<T>, options: {
@@ -44,6 +30,7 @@ declare class FrontendLifecycleOwner implements FrontendLifecycle {
44
30
  }): void;
45
31
  get isDisposed(): boolean;
46
32
  /** Abort owned work and dispose owned resources in reverse order. */
33
+ private closing?;
47
34
  dispose(): Promise<void>;
48
35
  }
49
36
 
@@ -1,19 +1,5 @@
1
1
  import { WorkRegistry } from './work_registry.js';
2
2
 
3
- /**
4
- * Production lifecycle owners for deliberately detached frontend work
5
- * (18A D-FAS-01 §2.2). Two natural owners exist per environment:
6
- *
7
- * page lifecycle — default for PageController work; aborted and
8
- * disposed when that page ends
9
- * application lifecycle — explicit for work that intentionally survives
10
- * page navigation; disposed with the environment
11
- *
12
- * PageControllers/client services register their own deliberately detached
13
- * finite work through their injected lifecycle owner — tests do not repair
14
- * unowned product code by wrapping it in track() from the outside.
15
- */
16
-
17
3
  interface FrontendLifecycle {
18
4
  readonly signal: AbortSignal;
19
5
  track<T>(work: Promise<T>, options: {
@@ -44,6 +30,7 @@ declare class FrontendLifecycleOwner implements FrontendLifecycle {
44
30
  }): void;
45
31
  get isDisposed(): boolean;
46
32
  /** Abort owned work and dispose owned resources in reverse order. */
33
+ private closing?;
47
34
  dispose(): Promise<void>;
48
35
  }
49
36
 
@@ -1,3 +1,4 @@
1
+ import { cleanupAll } from "../controller/cleanup.js";
1
2
  import { FrontendWorkError } from "./work_registry.js";
2
3
  class FrontendLifecycleOwner {
3
4
  constructor(registry, owner) {
@@ -33,15 +34,12 @@ class FrontendLifecycleOwner {
33
34
  get isDisposed() {
34
35
  return this.disposed;
35
36
  }
36
- /** Abort owned work and dispose owned resources in reverse order. */
37
- async dispose() {
38
- if (this.disposed) return;
37
+ dispose() {
38
+ if (this.closing) return this.closing;
39
39
  this.disposed = true;
40
40
  this.abortController.abort();
41
- for (const resource of this.resources.reverse()) {
42
- await resource.dispose();
43
- }
44
- this.resources.length = 0;
41
+ const resources = this.resources.splice(0).reverse();
42
+ return this.closing = cleanupAll(resources.map((resource) => () => resource.dispose()), "Wood frontend lifecycle cleanup failed");
45
43
  }
46
44
  }
47
45
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/frontend/frontend_lifecycle.ts"],"sourcesContent":["/**\n * Production lifecycle owners for deliberately detached frontend work\n * (18A D-FAS-01 §2.2). Two natural owners exist per environment:\n *\n * page lifecycle — default for PageController work; aborted and\n * disposed when that page ends\n * application lifecycle — explicit for work that intentionally survives\n * page navigation; disposed with the environment\n *\n * PageControllers/client services register their own deliberately detached\n * finite work through their injected lifecycle owner — tests do not repair\n * unowned product code by wrapping it in track() from the outside.\n */\nimport { WorkRegistry, FrontendWorkError } from './work_registry.js';\n\nexport interface FrontendLifecycle {\n readonly signal: AbortSignal;\n\n track<T>(\n work: Promise<T>,\n options: {\n label: string;\n kind?: string;\n },\n ): Promise<T>;\n\n own(resource: { dispose(): void | Promise<void> }): void;\n}\n\nexport class FrontendLifecycleOwner implements FrontendLifecycle {\n private readonly abortController = new AbortController();\n private readonly resources: Array<{ dispose(): void | Promise<void> }> = [];\n private disposed = false;\n\n constructor(\n private readonly registry: WorkRegistry,\n /** Diagnostic owner identity, e.g. `page main.chat_dom` or `application`. */\n readonly owner: string,\n ) {}\n\n get signal(): AbortSignal {\n return this.abortController.signal;\n }\n\n track<T>(work: Promise<T>, options: { label: string; kind?: string }): Promise<T> {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot track \"${options.label}\": lifecycle \"${this.owner}\" is disposed. ` +\n 'Work started after a page/environment ended is a product lifecycle bug.',\n );\n }\n return this.registry.track(work, {\n label: options.label,\n kind: options.kind ?? 'tracked',\n owner: this.owner,\n });\n }\n\n own(resource: { dispose(): void | Promise<void> }): void {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot own a resource: lifecycle \"${this.owner}\" is disposed.`,\n );\n }\n this.resources.push(resource);\n }\n\n get isDisposed(): boolean {\n return this.disposed;\n }\n\n /** Abort owned work and dispose owned resources in reverse order. */\n async dispose(): Promise<void> {\n if (this.disposed) return;\n this.disposed = true;\n this.abortController.abort();\n for (const resource of this.resources.reverse()) {\n await resource.dispose();\n }\n this.resources.length = 0;\n }\n}\n"],"mappings":"AAaA,SAAuB,yBAAyB;AAgBzC,MAAM,uBAAoD;AAAA,EAK/D,YACmB,UAER,OACT;AAHiB;AAER;AAPX,SAAiB,kBAAkB,IAAI,gBAAgB;AACvD,SAAiB,YAAwD,CAAC;AAC1E,SAAQ,WAAW;AAAA,EAMhB;AAAA,EAEH,IAAI,SAAsB;AACxB,WAAO,KAAK,gBAAgB;AAAA,EAC9B;AAAA,EAEA,MAAS,MAAkB,SAAuD;AAChF,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,iBAAiB,QAAQ,KAAK,iBAAiB,KAAK,KAAK;AAAA,MAE3D;AAAA,IACF;AACA,WAAO,KAAK,SAAS,MAAM,MAAM;AAAA,MAC/B,OAAO,QAAQ;AAAA,MACf,MAAM,QAAQ,QAAQ;AAAA,MACtB,OAAO,KAAK;AAAA,IACd,CAAC;AAAA,EACH;AAAA,EAEA,IAAI,UAAqD;AACvD,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,qCAAqC,KAAK,KAAK;AAAA,MACjD;AAAA,IACF;AACA,SAAK,UAAU,KAAK,QAAQ;AAAA,EAC9B;AAAA,EAEA,IAAI,aAAsB;AACxB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,MAAM,UAAyB;AAC7B,QAAI,KAAK,SAAU;AACnB,SAAK,WAAW;AAChB,SAAK,gBAAgB,MAAM;AAC3B,eAAW,YAAY,KAAK,UAAU,QAAQ,GAAG;AAC/C,YAAM,SAAS,QAAQ;AAAA,IACzB;AACA,SAAK,UAAU,SAAS;AAAA,EAC1B;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/frontend/frontend_lifecycle.ts"],"sourcesContent":["/**\n * Production lifecycle owners for deliberately detached frontend work\n * (18A D-FAS-01 §2.2). Two natural owners exist per environment:\n *\n * page lifecycle — default for PageController work; aborted and\n * disposed when that page ends\n * application lifecycle — explicit for work that intentionally survives\n * page navigation; disposed with the environment\n *\n * PageControllers/client services register their own deliberately detached\n * finite work through their injected lifecycle owner — tests do not repair\n * unowned product code by wrapping it in track() from the outside.\n */\nimport { cleanupAll } from '../controller/cleanup.js';\nimport { WorkRegistry, FrontendWorkError } from './work_registry.js';\n\nexport interface FrontendLifecycle {\n readonly signal: AbortSignal;\n\n track<T>(\n work: Promise<T>,\n options: {\n label: string;\n kind?: string;\n },\n ): Promise<T>;\n\n own(resource: { dispose(): void | Promise<void> }): void;\n}\n\nexport class FrontendLifecycleOwner implements FrontendLifecycle {\n private readonly abortController = new AbortController();\n private readonly resources: Array<{ dispose(): void | Promise<void> }> = [];\n private disposed = false;\n\n constructor(\n private readonly registry: WorkRegistry,\n /** Diagnostic owner identity, e.g. `page main.chat_dom` or `application`. */\n readonly owner: string,\n ) {}\n\n get signal(): AbortSignal {\n return this.abortController.signal;\n }\n\n track<T>(work: Promise<T>, options: { label: string; kind?: string }): Promise<T> {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot track \"${options.label}\": lifecycle \"${this.owner}\" is disposed. ` +\n 'Work started after a page/environment ended is a product lifecycle bug.',\n );\n }\n return this.registry.track(work, {\n label: options.label,\n kind: options.kind ?? 'tracked',\n owner: this.owner,\n });\n }\n\n own(resource: { dispose(): void | Promise<void> }): void {\n if (this.disposed) {\n throw new FrontendWorkError(\n `cannot own a resource: lifecycle \"${this.owner}\" is disposed.`,\n );\n }\n this.resources.push(resource);\n }\n\n get isDisposed(): boolean {\n return this.disposed;\n }\n\n /** Abort owned work and dispose owned resources in reverse order. */\n private closing?: Promise<void>;\n dispose(): Promise<void> {\n if (this.closing) return this.closing;\n this.disposed = true;\n this.abortController.abort();\n const resources = this.resources.splice(0).reverse();\n return this.closing = cleanupAll(resources.map(resource => () => resource.dispose()), 'Wood frontend lifecycle cleanup failed');\n }\n}\n"],"mappings":"AAaA,SAAS,kBAAkB;AAC3B,SAAuB,yBAAyB;AAgBzC,MAAM,uBAAoD;AAAA,EAK/D,YACmB,UAER,OACT;AAHiB;AAER;AAPX,SAAiB,kBAAkB,IAAI,gBAAgB;AACvD,SAAiB,YAAwD,CAAC;AAC1E,SAAQ,WAAW;AAAA,EAMhB;AAAA,EAEH,IAAI,SAAsB;AACxB,WAAO,KAAK,gBAAgB;AAAA,EAC9B;AAAA,EAEA,MAAS,MAAkB,SAAuD;AAChF,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,iBAAiB,QAAQ,KAAK,iBAAiB,KAAK,KAAK;AAAA,MAE3D;AAAA,IACF;AACA,WAAO,KAAK,SAAS,MAAM,MAAM;AAAA,MAC/B,OAAO,QAAQ;AAAA,MACf,MAAM,QAAQ,QAAQ;AAAA,MACtB,OAAO,KAAK;AAAA,IACd,CAAC;AAAA,EACH;AAAA,EAEA,IAAI,UAAqD;AACvD,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI;AAAA,QACR,qCAAqC,KAAK,KAAK;AAAA,MACjD;AAAA,IACF;AACA,SAAK,UAAU,KAAK,QAAQ;AAAA,EAC9B;AAAA,EAEA,IAAI,aAAsB;AACxB,WAAO,KAAK;AAAA,EACd;AAAA,EAIA,UAAyB;AACvB,QAAI,KAAK,QAAS,QAAO,KAAK;AAC9B,SAAK,WAAW;AAChB,SAAK,gBAAgB,MAAM;AAC3B,UAAM,YAAY,KAAK,UAAU,OAAO,CAAC,EAAE,QAAQ;AACnD,WAAO,KAAK,UAAU,WAAW,UAAU,IAAI,cAAY,MAAM,SAAS,QAAQ,CAAC,GAAG,wCAAwC;AAAA,EAChI;AACF;","names":[]}
@@ -24,6 +24,7 @@ __export(frontend_surface_exports, {
24
24
  module.exports = __toCommonJS(frontend_surface_exports);
25
25
  var import_ioc = require("@noego/ioc");
26
26
  var import_layout_preservation = require("../navigation/layout_preservation.js");
27
+ var import_cleanup = require("../controller/cleanup.js");
27
28
  var import_controller_lifecycle = require("../controller/controller_lifecycle.js");
28
29
  var import_page_catalog = require("./page_catalog.js");
29
30
  var import_work_registry = require("./work_registry.js");
@@ -34,26 +35,6 @@ function registerControllerUnlessBound(scope, ControllerClass) {
34
35
  if (typeof registrable.isRegistered === "function" && registrable.isRegistered(ControllerClass)) return;
35
36
  if (typeof scope.registerClass === "function") scope.registerClass(ControllerClass);
36
37
  }
37
- function guardControllerLifecycleOnce(instance) {
38
- if (!instance || typeof instance !== "object") return;
39
- const target = instance;
40
- for (const hook of ["dispose", "destroy"]) {
41
- const original = target[hook];
42
- if (typeof original !== "function") continue;
43
- if (Object.prototype.hasOwnProperty.call(target, hook)) continue;
44
- let called = false;
45
- Object.defineProperty(target, hook, {
46
- configurable: true,
47
- writable: true,
48
- enumerable: false,
49
- value: function guarded(...args) {
50
- if (called) return void 0;
51
- called = true;
52
- return original.apply(this, args);
53
- }
54
- });
55
- }
56
- }
57
38
  function createFrontendSurface(options) {
58
39
  const { catalog, rendererRoot, registry, navigation } = options;
59
40
  const flushHooks = options.flushHooks ?? [];
@@ -61,6 +42,16 @@ function createFrontendSurface(options) {
61
42
  let active = null;
62
43
  let transitioning = false;
63
44
  let disposed = false;
45
+ let navigationQueue = Promise.resolve();
46
+ let closing;
47
+ const retainedPageScopes = /* @__PURE__ */ new Map();
48
+ const locallyOwnedControllers = /* @__PURE__ */ new WeakSet();
49
+ const markController = (instance, token) => {
50
+ const root = rendererRoot;
51
+ if (!root.isRegistered?.(token) && instance && typeof instance === "object") locallyOwnedControllers.add(instance);
52
+ (0, import_controller_lifecycle.guardControllerLifecycleOnce)(instance);
53
+ };
54
+ const disposeLocalController = (instance) => instance && typeof instance === "object" && locallyOwnedControllers.has(instance) ? (0, import_controller_lifecycle.disposeControllerLifecycle)(instance) : Promise.resolve();
64
55
  const currentOwner = () => active ? `page ${active.identity}` : "application";
65
56
  async function constructPage(view, params, query, preserved = []) {
66
57
  const identity = (0, import_page_catalog.pageIdentity)(view);
@@ -72,91 +63,111 @@ function createFrontendSurface(options) {
72
63
  const ControllerClass = await options.resolveController(view.controller, view);
73
64
  const scope = rendererRoot.extend();
74
65
  const lifecycle = new import_frontend_lifecycle.FrontendLifecycleOwner(registry, `page ${identity}`);
75
- scope.registerFunction?.(import_frontend_lifecycle.FrontendLifecycleOwner, () => lifecycle, { loadAs: import_ioc.LoadAs.Scoped });
76
- await options.configurePageScope?.(scope, view, {
77
- kind: "page",
78
- controller: view.controller
79
- });
80
- registerControllerUnlessBound(scope, ControllerClass);
81
- const layouts = [...preserved];
82
- for (let index = preserved.length; index < view.layouts.length; index += 1) {
83
- const layoutIdentity = view.layoutControllers?.[index];
84
- if (!layoutIdentity) continue;
85
- const preserve = view.layoutPreserve?.[index] === true;
86
- const LayoutClass = await options.resolveController(layoutIdentity, view);
87
- const ownScope = preserve ? rendererRoot.extend() : null;
88
- const ownLifecycle = ownScope ? new import_frontend_lifecycle.FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`) : null;
89
- const layoutScope = ownScope ?? scope;
90
- if (ownScope) {
91
- ownScope.registerFunction?.(import_frontend_lifecycle.FrontendLifecycleOwner, () => ownLifecycle, { loadAs: import_ioc.LoadAs.Scoped });
92
- await options.configurePageScope?.(ownScope, view, {
93
- kind: "layout",
94
- layoutIndex: index,
95
- controller: layoutIdentity
66
+ const rollback = [
67
+ () => import_ioc.ExecutionContext.run(scope, () => scope.dispose?.()),
68
+ () => lifecycle.dispose()
69
+ ];
70
+ try {
71
+ scope.registerFunction?.(import_frontend_lifecycle.FrontendLifecycleOwner, () => lifecycle, { loadAs: import_ioc.LoadAs.Scoped });
72
+ await import_ioc.ExecutionContext.run(scope, () => options.configurePageScope?.(scope, view, { kind: "page", controller: view.controller }));
73
+ registerControllerUnlessBound(scope, ControllerClass);
74
+ const layouts = [...preserved];
75
+ for (let index = preserved.length; index < view.layouts.length; index += 1) {
76
+ const layoutIdentity = view.layoutControllers?.[index];
77
+ if (!layoutIdentity) continue;
78
+ const preserve = view.layoutPreserve?.[index] === true;
79
+ const LayoutClass = await options.resolveController(layoutIdentity, view);
80
+ const ownScope = preserve ? rendererRoot.extend() : null;
81
+ const ownLifecycle = ownScope ? new import_frontend_lifecycle.FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`) : null;
82
+ const layoutScope = ownScope ?? scope;
83
+ if (ownScope) {
84
+ rollback.push(() => import_ioc.ExecutionContext.run(ownScope, () => ownScope.dispose?.()));
85
+ rollback.push(() => ownLifecycle.dispose());
86
+ ownScope.registerFunction?.(import_frontend_lifecycle.FrontendLifecycleOwner, () => ownLifecycle, { loadAs: import_ioc.LoadAs.Scoped });
87
+ await import_ioc.ExecutionContext.run(ownScope, () => options.configurePageScope?.(ownScope, view, {
88
+ kind: "layout",
89
+ layoutIndex: index,
90
+ controller: layoutIdentity
91
+ }));
92
+ }
93
+ registerControllerUnlessBound(layoutScope, LayoutClass);
94
+ const instance = await import_ioc.ExecutionContext.run(
95
+ layoutScope,
96
+ () => layoutScope.get(LayoutClass)
97
+ );
98
+ markController(instance, LayoutClass);
99
+ rollback.push(() => import_ioc.ExecutionContext.run(layoutScope, () => disposeLocalController(instance)));
100
+ layouts.push({
101
+ index,
102
+ path: view.layouts[index],
103
+ controller: layoutIdentity,
104
+ preserve,
105
+ instance,
106
+ scope: layoutScope,
107
+ ownScope,
108
+ ownLifecycle
96
109
  });
97
110
  }
98
- registerControllerUnlessBound(layoutScope, LayoutClass);
99
- const instance = await import_ioc.ExecutionContext.run(
100
- layoutScope,
101
- () => layoutScope.get(LayoutClass)
111
+ const controller = await import_ioc.ExecutionContext.run(
112
+ scope,
113
+ () => scope.get(ControllerClass)
102
114
  );
103
- guardControllerLifecycleOnce(instance);
104
- layouts.push({
105
- index,
106
- path: view.layouts[index],
107
- controller: layoutIdentity,
108
- preserve,
109
- instance,
110
- scope: layoutScope,
111
- ownScope,
112
- ownLifecycle
115
+ markController(controller, ControllerClass);
116
+ rollback.push(() => import_ioc.ExecutionContext.run(scope, () => disposeLocalController(controller)));
117
+ if (typeof controller.initialize === "function") {
118
+ await registry.track(
119
+ Promise.resolve(
120
+ import_ioc.ExecutionContext.run(
121
+ scope,
122
+ () => controller.initialize({ params, query })
123
+ )
124
+ ).then(() => void 0),
125
+ { label: `initialize ${identity}`, kind: "initialize", owner: `page ${identity}` }
126
+ );
127
+ }
128
+ const handle = (0, import_frontend_page.createFrontendPage)({
129
+ identity,
130
+ controller,
131
+ scope,
132
+ layouts,
133
+ onClose: async () => {
134
+ await disposePage(identity);
135
+ }
113
136
  });
114
- }
115
- const controller = await import_ioc.ExecutionContext.run(
116
- scope,
117
- () => scope.get(ControllerClass)
118
- );
119
- guardControllerLifecycleOnce(controller);
120
- if (typeof controller.initialize === "function") {
121
- await registry.track(
122
- Promise.resolve(
123
- import_ioc.ExecutionContext.run(
124
- scope,
125
- () => controller.initialize({ params, query })
126
- )
127
- ).then(() => void 0),
128
- { label: `initialize ${identity}`, kind: "initialize", owner: `page ${identity}` }
129
- );
130
- }
131
- const handle = (0, import_frontend_page.createFrontendPage)({
132
- identity,
133
- controller,
134
- scope,
135
- layouts,
136
- onClose: async () => {
137
- await disposePage(identity);
137
+ return { identity, view, scope, lifecycle, handle, controller, layouts };
138
+ } catch (error) {
139
+ try {
140
+ await (0, import_cleanup.cleanupAll)(rollback.reverse(), "Wood page rollback failed");
141
+ } catch (cleanupError) {
142
+ throw Object.assign(new Error("Wood page construction and cleanup failed"), { cause: error, errors: [error, cleanupError] });
138
143
  }
139
- });
140
- return { identity, view, scope, lifecycle, handle, controller, layouts };
144
+ throw error;
145
+ }
141
146
  }
142
147
  async function endPage(page, preservedDepth = 0) {
143
148
  page.handle.invalidate();
144
- await (0, import_controller_lifecycle.disposeControllerLifecycle)(page.controller);
149
+ const steps = [
150
+ () => import_ioc.ExecutionContext.run(page.scope, () => disposeLocalController(page.controller))
151
+ ];
145
152
  for (const layout of page.layouts) {
146
153
  if (layout.index < preservedDepth) continue;
147
- await (0, import_controller_lifecycle.disposeControllerLifecycle)(layout.instance);
154
+ steps.push(() => import_ioc.ExecutionContext.run(layout.scope, () => disposeLocalController(layout.instance)));
155
+ steps.push(() => layout.ownLifecycle?.dispose());
156
+ if (layout.ownScope) steps.push(() => import_ioc.ExecutionContext.run(layout.ownScope, () => layout.ownScope.dispose?.()));
148
157
  }
149
- await page.lifecycle.dispose();
150
- if (typeof page.scope.dispose === "function") {
151
- await page.scope.dispose();
158
+ const carriesPageScope = page.layouts.some((layout) => layout.index < preservedDepth && layout.scope === page.scope);
159
+ if (carriesPageScope) retainedPageScopes.set(page.scope, page.lifecycle);
160
+ else {
161
+ steps.push(() => page.lifecycle.dispose());
162
+ steps.push(() => import_ioc.ExecutionContext.run(page.scope, () => page.scope.dispose?.()));
152
163
  }
153
- for (const layout of page.layouts) {
154
- if (layout.index < preservedDepth) continue;
155
- await layout.ownLifecycle?.dispose();
156
- if (layout.ownScope && typeof layout.ownScope.dispose === "function") {
157
- await layout.ownScope.dispose();
158
- }
164
+ for (const [scope, lifecycle] of retainedPageScopes) {
165
+ if (active?.layouts.some((layout) => layout.scope === scope)) continue;
166
+ retainedPageScopes.delete(scope);
167
+ steps.push(() => lifecycle.dispose());
168
+ steps.push(() => import_ioc.ExecutionContext.run(scope, () => scope.dispose?.()));
159
169
  }
170
+ await (0, import_cleanup.cleanupAll)(steps, "Wood page cleanup failed");
160
171
  }
161
172
  async function disposePage(identity) {
162
173
  if (!active || active.identity !== identity) return;
@@ -165,6 +176,7 @@ function createFrontendSurface(options) {
165
176
  await endPage(ended);
166
177
  }
167
178
  async function transitionTo(entry) {
179
+ if (disposed) return;
168
180
  const view = catalog.resolve(entry.page);
169
181
  const previous = active;
170
182
  const depth = previous ? (0, import_layout_preservation.computePreservedLayoutDepth)(
@@ -174,16 +186,23 @@ function createFrontendSurface(options) {
174
186
  ) : 0;
175
187
  const carried = previous ? previous.layouts.filter((layout) => layout.index < depth) : [];
176
188
  const next = await constructPage(view, entry.params, entry.query, carried);
189
+ if (disposed) {
190
+ await endPage(next, depth);
191
+ return;
192
+ }
177
193
  active = next;
178
194
  if (previous) {
179
195
  await endPage(previous, depth);
180
196
  }
181
197
  }
182
- navigation.subscribe((entry) => {
198
+ const unsubscribe = navigation.subscribe((entry) => {
183
199
  if (!entry || transitioning || disposed) return;
184
200
  if (active && entry.page === active.identity) return;
185
201
  if (!active) return;
186
- registry.track(transitionTo(entry), {
202
+ const transition = navigationQueue.then(() => transitionTo(entry));
203
+ navigationQueue = transition.catch(() => {
204
+ });
205
+ registry.track(transition, {
187
206
  label: `navigate ${active.identity} -> ${entry.page}`,
188
207
  kind: "navigation",
189
208
  owner: currentOwner()
@@ -192,6 +211,7 @@ function createFrontendSurface(options) {
192
211
  return {
193
212
  navigation,
194
213
  async open(openOptions) {
214
+ if (disposed || transitioning) throw new import_page_catalog.PageCatalogError("Wood frontend is closed or opening a page");
195
215
  if (!openOptions || typeof openOptions.page !== "string" || !openOptions.page.length) {
196
216
  throw new import_page_catalog.PageCatalogError("frontend.open() requires an explicit { page } identity");
197
217
  }
@@ -206,7 +226,12 @@ function createFrontendSurface(options) {
206
226
  transitioning = true;
207
227
  try {
208
228
  navigation.replace((0, import_page_catalog.pageIdentity)(view), params, query);
209
- active = await constructPage(view, params, query);
229
+ const opened = await constructPage(view, params, query);
230
+ if (disposed) {
231
+ await endPage(opened);
232
+ throw new import_page_catalog.PageCatalogError("Wood frontend was disposed during page construction");
233
+ }
234
+ active = opened;
210
235
  } finally {
211
236
  transitioning = false;
212
237
  }
@@ -216,7 +241,7 @@ function createFrontendSurface(options) {
216
241
  return active ? active.handle.page : null;
217
242
  },
218
243
  async act(action) {
219
- await registry.track(
244
+ void registry.track(
220
245
  Promise.resolve().then(() => action()).then(() => void 0),
221
246
  { label: "act()", kind: "tracked", owner: currentOwner() }
222
247
  ).catch(() => {
@@ -229,15 +254,16 @@ function createFrontendSurface(options) {
229
254
  get errors() {
230
255
  return registry.errors();
231
256
  },
232
- async dispose() {
233
- if (disposed) return;
257
+ dispose() {
258
+ if (closing) return closing;
234
259
  disposed = true;
235
- if (active) {
236
- const ended = active;
237
- active = null;
238
- await endPage(ended);
239
- }
240
- await application.dispose();
260
+ unsubscribe();
261
+ const ended = active;
262
+ active = null;
263
+ return closing = (0, import_cleanup.cleanupAll)([
264
+ () => ended ? endPage(ended) : void 0,
265
+ () => application.dispose()
266
+ ], "Wood frontend cleanup failed");
241
267
  }
242
268
  };
243
269
  }