@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
@@ -3,7 +3,6 @@ import { InProcessRouterTransport } from "../transport/in_process_router_transpo
3
3
  import { buildTransportBridge } from "../transport/build_transport_bridge.js";
4
4
  import { RouterBridgeEvents } from "./create_router_bridge.js";
5
5
  import { Navigation } from "../navigation/index.js";
6
- import { createWoodRoot } from "../runtime/get_container.js";
7
6
  import { WorkRegistry } from "../frontend/work_registry.js";
8
7
  import { TrackedTransport } from "../frontend/tracked_transport.js";
9
8
  import {
@@ -11,8 +10,15 @@ import {
11
10
  } from "../frontend/frontend_surface.js";
12
11
  import { TestWoodError, WOOD_FRONTEND_BRIDGE } from "./test_wood.js";
13
12
  import { importDefaultExport } from "./wood_config_source.js";
14
- function controllerLoader(source, importer) {
13
+ import { leaseDomAnimation } from "./wood_dom_animation.js";
14
+ import {
15
+ buildRecordedEnvironment,
16
+ cleanupAll,
17
+ TestWoodCleanupError
18
+ } from "./registration_only_root.js";
19
+ function controllerLoader(source, importer, preloaded) {
15
20
  const cache = /* @__PURE__ */ new Map();
21
+ for (const [identity, controller] of preloaded) cache.set(identity, Promise.resolve(controller));
16
22
  return (identity) => {
17
23
  let pending = cache.get(identity);
18
24
  if (!pending) {
@@ -48,11 +54,16 @@ class MountKeeper {
48
54
  this.host = host;
49
55
  this.mounted = null;
50
56
  this.mountedFor = null;
57
+ this.animation = null;
51
58
  }
52
59
  get target() {
53
60
  if (!this.mounted) throw new TestWoodError("no page is mounted; open(page) first");
54
61
  return this.mounted.target;
55
62
  }
63
+ /** The Web Animations lease held for the current mount (null when nothing is mounted). */
64
+ get animationLease() {
65
+ return this.animation;
66
+ }
56
67
  async flush() {
57
68
  await this.mounted?.flush?.();
58
69
  }
@@ -62,14 +73,29 @@ class MountKeeper {
62
73
  if (!current) return;
63
74
  const target = document.createElement("div");
64
75
  document.body.appendChild(target);
65
- this.mounted = this.host(current.identity, current, target);
76
+ const lease = leaseDomAnimation(target);
77
+ try {
78
+ this.mounted = this.host(current.identity, current, target);
79
+ } catch (error) {
80
+ lease.release();
81
+ target.remove();
82
+ throw error;
83
+ }
84
+ this.animation = lease;
66
85
  this.mountedFor = current;
67
86
  }
68
87
  unmount() {
69
- this.mounted?.unmount();
70
- this.mounted?.target.remove();
88
+ const mounted = this.mounted;
89
+ const lease = this.animation;
71
90
  this.mounted = null;
72
91
  this.mountedFor = null;
92
+ this.animation = null;
93
+ try {
94
+ mounted?.unmount();
95
+ } finally {
96
+ mounted?.target.remove();
97
+ lease?.release();
98
+ }
73
99
  }
74
100
  }
75
101
  function domDriver(keeper) {
@@ -106,28 +132,54 @@ function domDriver(keeper) {
106
132
  };
107
133
  }
108
134
  async function buildRendererTier(options) {
109
- const { source, router, catalog, operations, importer, hooks, dom } = options;
135
+ const {
136
+ source,
137
+ router,
138
+ catalog,
139
+ operations,
140
+ importer,
141
+ hooks,
142
+ dom,
143
+ ioc,
144
+ registrations,
145
+ controllerClasses
146
+ } = options;
110
147
  const keeper = dom ? new MountKeeper(await loadPageHost(source, importer, dom)) : null;
111
- const rendererRoot = createWoodRoot();
112
- await hooks?.configureRendererRoot?.(rendererRoot);
113
148
  const registry = new WorkRegistry();
114
- const navigation = new Navigation();
149
+ const defaultNavigation = new Navigation();
115
150
  const innerTransport = new InProcessRouterTransport(router, hooks?.events ?? new RouterBridgeEvents());
116
151
  let currentOwner = () => "application";
117
152
  const transport = new TrackedTransport(innerTransport, registry, () => currentOwner());
118
153
  const { bridge } = buildTransportBridge(operations, transport);
119
- const registrable = rendererRoot;
120
- registrable.registerFunction(Navigation, () => navigation, { loadAs: LoadAs.Singleton });
121
- registrable.registerFunction(WOOD_FRONTEND_BRIDGE, () => bridge, { loadAs: LoadAs.Singleton });
122
- const loadController = controllerLoader(source, importer);
123
- const frontend = createFrontendSurface({
124
- catalog,
125
- rendererRoot,
126
- registry,
127
- navigation,
128
- resolveController: (identity) => loadController(identity),
129
- ...hooks?.configurePageScope ? { configurePageScope: (scope, view, context) => hooks.configurePageScope(scope, view, context) } : {}
130
- });
154
+ registrations.root.registerFunction(Navigation, () => defaultNavigation, { loadAs: LoadAs.Singleton });
155
+ registrations.root.registerFunction(WOOD_FRONTEND_BRIDGE, () => bridge, { loadAs: LoadAs.Singleton });
156
+ const rendererEnv = await buildRecordedEnvironment(ioc, registrations, "renderer");
157
+ const rendererRoot = rendererEnv.root;
158
+ let navigation;
159
+ const loadController = controllerLoader(source, importer, controllerClasses);
160
+ let frontend;
161
+ try {
162
+ navigation = await ExecutionContext.run(rendererRoot, () => rendererRoot.get(Navigation));
163
+ frontend = createFrontendSurface({
164
+ catalog,
165
+ rendererRoot,
166
+ registry,
167
+ navigation,
168
+ resolveController: (identity) => loadController(identity),
169
+ ...hooks?.configurePageScope ? { configurePageScope: (scope, view, context) => hooks.configurePageScope(scope, view, context) } : {}
170
+ });
171
+ } catch (error) {
172
+ try {
173
+ await cleanupAll("renderer build rollback failed", [
174
+ ["renderer mount", () => keeper?.unmount()],
175
+ ["renderer root", () => ExecutionContext.run(rendererRoot, () => rendererEnv.dispose())]
176
+ ]);
177
+ } catch (cleanupError) {
178
+ const failures = cleanupError instanceof TestWoodCleanupError ? cleanupError.failures : [{ owner: "renderer rollback", error: cleanupError }];
179
+ throw new TestWoodCleanupError("renderer build failed and rollback failed", failures, error);
180
+ }
181
+ throw error;
182
+ }
131
183
  currentOwner = () => {
132
184
  const current = frontend.current();
133
185
  return current ? `page ${current.identity}` : "application";
@@ -144,6 +196,14 @@ async function buildRendererTier(options) {
144
196
  await new Promise((resolve) => setTimeout(resolve, 0));
145
197
  await keeper.flush();
146
198
  };
199
+ let closing = null;
200
+ const dispose = () => closing ?? (closing = cleanupAll("renderer cleanup failed", [
201
+ ["renderer mount", () => keeper?.unmount()],
202
+ ["renderer work drain (before frontend)", () => ExecutionContext.run(rendererRoot, () => frontend.settle())],
203
+ ["renderer frontend", () => ExecutionContext.run(rendererRoot, () => frontend.dispose())],
204
+ ["renderer work drain (after frontend)", () => ExecutionContext.run(rendererRoot, () => frontend.settle())],
205
+ ["renderer root", () => ExecutionContext.run(rendererRoot, () => rendererEnv.dispose())]
206
+ ]));
147
207
  return {
148
208
  frontend,
149
209
  events: innerTransport.events,
@@ -168,21 +228,13 @@ async function buildRendererTier(options) {
168
228
  current: () => frontend.current(),
169
229
  settle,
170
230
  pendingWork: () => registry.pending(),
231
+ verify: () => rendererEnv.verify(),
171
232
  dom: keeper ? domDriver(keeper) : null,
172
- async dispose() {
173
- keeper?.unmount();
174
- await ExecutionContext.run(rendererRoot, async () => {
175
- await frontend.settle().catch(() => {
176
- });
177
- await frontend.dispose();
178
- await frontend.settle().catch(() => {
179
- });
180
- await rendererRoot.dispose();
181
- });
182
- }
233
+ dispose
183
234
  };
184
235
  }
185
236
  export {
237
+ MountKeeper,
186
238
  buildRendererTier
187
239
  };
188
240
  //# sourceMappingURL=wood_env_renderer.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/testing/wood_env_renderer.ts"],"sourcesContent":["/**\n * The renderer tier of a `testWood(configPath)` environment: a fresh renderer\n * root (mirroring the production process split), an environment-local\n * Navigation, the generated bridge client over an in-process transport into\n * the main router, and the frontend application surface over the selected\n * page catalog. With a DOM mode, the page's real component is mounted by the\n * app's host module and driven through a selector-based surface.\n */\nimport type { IContainer } from '@noego/ioc';\nimport { ExecutionContext, LoadAs } from '@noego/ioc';\nimport type { WoodRouter } from '../router/wood_router.js';\nimport { InProcessRouterTransport } from '../transport/in_process_router_transport.js';\nimport { buildTransportBridge } from '../transport/build_transport_bridge.js';\nimport { RouterBridgeEvents } from './create_router_bridge.js';\nimport type { OperationDef, ViewDef } from '../types/index.js';\nimport { Navigation } from '../navigation/index.js';\nimport { createWoodRoot } from '../runtime/get_container.js';\nimport type { PageCatalog } from '../frontend/page_catalog.js';\nimport { WorkRegistry } from '../frontend/work_registry.js';\nimport { TrackedTransport } from '../frontend/tracked_transport.js';\nimport {\n createFrontendSurface,\n type FrontendApplicationSurface,\n type PageScopeContext,\n} from '../frontend/frontend_surface.js';\nimport type { FrontendPage } from '../frontend/frontend_page.js';\nimport { TestWoodError, WOOD_FRONTEND_BRIDGE } from './test_wood.js';\nimport { importDefaultExport, type ModuleImporter, type WoodConfigSource } from './wood_config_source.js';\nimport type { WoodDomMode } from './wood_test_selection.js';\n\n/** What the app's `main.testing.compose` module may hand back for the renderer side. */\nexport interface WoodRendererHooks {\n /** Backend push emitter feeding renderer subscriptions (default: transport-owned). */\n events?: RouterBridgeEvents;\n /** Register app-owned renderer composition on the fresh renderer root. */\n configureRendererRoot?(root: IContainer): void | Promise<void>;\n /** Register per-scope renderer builtins on a new page/layout scope. */\n configurePageScope?(scope: IContainer, view: ViewDef, context: PageScopeContext): void | Promise<void>;\n}\n\n/** A page's real component mounted under a DOM driver (`renderer.testing.host`). */\nexport interface WoodMountedPage {\n readonly target: Element;\n flush?(): void | Promise<void>;\n unmount(): void;\n}\n\nexport type WoodPageHost = (identity: string, page: FrontendPage, target: Element) => WoodMountedPage;\n\nexport interface RendererTierOptions {\n source: WoodConfigSource;\n router: WoodRouter;\n catalog: PageCatalog;\n operations: Map<string, OperationDef>;\n importer: ModuleImporter;\n hooks: WoodRendererHooks | null;\n dom: WoodDomMode | null;\n}\n\nexport interface WoodDomDriver {\n readonly target: Element;\n click(selector: string): Promise<void>;\n type(selector: string, text: string): Promise<void>;\n submit(selector: string): Promise<void>;\n text(selector: string): Promise<string>;\n exists(selector: string): Promise<boolean>;\n}\n\nexport interface RendererTier {\n readonly frontend: FrontendApplicationSurface;\n readonly events: RouterBridgeEvents;\n readonly rendererRoot: IContainer;\n open(page: string, params?: Record<string, string>, query?: Record<string, string>): Promise<FrontendPage>;\n navigate(page: string, params?: Record<string, string>, query?: Record<string, string>): Promise<FrontendPage>;\n current(): FrontendPage | null;\n settle(): Promise<void>;\n pendingWork(): ReturnType<WorkRegistry['pending']>;\n dom: WoodDomDriver | null;\n dispose(): Promise<void>;\n}\n\ntype ControllerClass = new (...args: never[]) => unknown;\n\nfunction controllerLoader(source: WoodConfigSource, importer: ModuleImporter) {\n const cache = new Map<string, Promise<ControllerClass>>();\n return (identity: string): Promise<ControllerClass> => {\n let pending = cache.get(identity);\n if (!pending) {\n pending = importDefaultExport<ControllerClass>(\n importer,\n source.rendererControllerFile(identity),\n `renderer controller \"${identity}\"`,\n );\n cache.set(identity, pending);\n }\n return pending;\n };\n}\n\nasync function loadPageHost(\n source: WoodConfigSource,\n importer: ModuleImporter,\n dom: WoodDomMode,\n): Promise<WoodPageHost> {\n if (dom === 'chromium') {\n throw new TestWoodError(\"select({ dom: 'chromium' }) is not available yet; use 'happy-dom'\");\n }\n if (typeof document === 'undefined') {\n throw new TestWoodError(\n \"select({ dom: 'happy-dom' }) requires a DOM environment (// @vitest-environment happy-dom)\",\n );\n }\n const hostPath = source.hookModule(source.config.renderer.testing?.host);\n if (!hostPath) {\n throw new TestWoodError(\n 'select({ dom }) requires renderer.testing.host in wood.config.yml (a module whose default export mounts a page)',\n );\n }\n return importDefaultExport<WoodPageHost>(importer, hostPath, 'renderer.testing.host');\n}\n\n/** Keeps the mounted component in step with the surface's current page. */\nclass MountKeeper {\n private mounted: WoodMountedPage | null = null;\n private mountedFor: FrontendPage | null = null;\n\n constructor(private readonly host: WoodPageHost) {}\n\n get target(): Element {\n if (!this.mounted) throw new TestWoodError('no page is mounted; open(page) first');\n return this.mounted.target;\n }\n\n async flush(): Promise<void> {\n await this.mounted?.flush?.();\n }\n\n sync(current: FrontendPage | null): void {\n if (current === this.mountedFor) return;\n this.unmount();\n if (!current) return;\n const target = document.createElement('div');\n document.body.appendChild(target);\n this.mounted = this.host(current.identity, current, target);\n this.mountedFor = current;\n }\n\n unmount(): void {\n this.mounted?.unmount();\n this.mounted?.target.remove();\n this.mounted = null;\n this.mountedFor = null;\n }\n}\n\nfunction domDriver(keeper: MountKeeper): WoodDomDriver {\n const find = (selector: string): Element => {\n const found = keeper.target.querySelector(selector);\n if (!found) throw new TestWoodError(`no element matches \"${selector}\"`);\n return found;\n };\n return {\n get target() {\n return keeper.target;\n },\n async click(selector) {\n (find(selector) as HTMLElement).click();\n await keeper.flush();\n },\n async type(selector, text) {\n const input = find(selector) as HTMLInputElement;\n input.focus();\n input.value = text;\n input.dispatchEvent(new Event('input', { bubbles: true }));\n await keeper.flush();\n },\n async submit(selector) {\n find(selector).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));\n await keeper.flush();\n },\n async text(selector) {\n return keeper.target.querySelector(selector)?.textContent ?? '';\n },\n async exists(selector) {\n return keeper.target.querySelector(selector) !== null;\n },\n };\n}\n\nexport async function buildRendererTier(options: RendererTierOptions): Promise<RendererTier> {\n const { source, router, catalog, operations, importer, hooks, dom } = options;\n const keeper = dom ? new MountKeeper(await loadPageHost(source, importer, dom)) : null;\n\n const rendererRoot = createWoodRoot();\n await hooks?.configureRendererRoot?.(rendererRoot);\n\n const registry = new WorkRegistry();\n const navigation = new Navigation();\n const innerTransport = new InProcessRouterTransport(router, hooks?.events ?? new RouterBridgeEvents());\n let currentOwner: () => string = () => 'application';\n const transport = new TrackedTransport(innerTransport, registry, () => currentOwner());\n const { bridge } = buildTransportBridge(operations, transport);\n\n const registrable = rendererRoot as unknown as {\n registerFunction(token: unknown, factory: () => unknown, options: { loadAs: LoadAs }): void;\n };\n registrable.registerFunction(Navigation, () => navigation, { loadAs: LoadAs.Singleton });\n registrable.registerFunction(WOOD_FRONTEND_BRIDGE, () => bridge, { loadAs: LoadAs.Singleton });\n\n const loadController = controllerLoader(source, importer);\n const frontend = createFrontendSurface({\n catalog,\n rendererRoot: rendererRoot as never,\n registry,\n navigation,\n resolveController: (identity) => loadController(identity),\n ...(hooks?.configurePageScope\n ? { configurePageScope: (scope, view, context) => hooks.configurePageScope!(scope as IContainer, view, context) }\n : {}),\n });\n currentOwner = () => {\n const current = frontend.current();\n return current ? `page ${current.identity}` : 'application';\n };\n\n const syncMount = async (): Promise<void> => {\n if (!keeper) return;\n keeper.sync(frontend.current());\n await keeper.flush();\n };\n const settle = async (): Promise<void> => {\n await frontend.settle();\n if (!keeper) return;\n await syncMount();\n await new Promise<void>((resolve) => setTimeout(resolve, 0));\n await keeper.flush();\n };\n\n return {\n frontend,\n events: innerTransport.events,\n rendererRoot,\n async open(page, params, query) {\n const opened = await frontend.open({\n page,\n ...(params ? { params } : {}),\n ...(query ? { query } : {}),\n });\n await syncMount();\n return opened;\n },\n async navigate(page, params, query) {\n if (!frontend.current()) throw new TestWoodError('navigate() needs an open page; call open(page) first');\n await frontend.act(() => navigation.go(page, params, query));\n await syncMount();\n const current = frontend.current();\n if (!current) throw new TestWoodError(`navigate(\"${page}\") left no page open`);\n return current;\n },\n current: () => frontend.current(),\n settle,\n pendingWork: () => registry.pending(),\n dom: keeper ? domDriver(keeper) : null,\n async dispose() {\n keeper?.unmount();\n await ExecutionContext.run(rendererRoot as never, async () => {\n // Drain owned work while the transport still reaches a live main\n // root, end the pages, then drain the teardown's own deliveries.\n await frontend.settle().catch(() => {});\n await frontend.dispose();\n await frontend.settle().catch(() => {});\n await rendererRoot.dispose();\n });\n },\n };\n}\n"],"mappings":"AASA,SAAS,kBAAkB,cAAc;AAEzC,SAAS,gCAAgC;AACzC,SAAS,4BAA4B;AACrC,SAAS,0BAA0B;AAEnC,SAAS,kBAAkB;AAC3B,SAAS,sBAAsB;AAE/B,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC;AAAA,EACE;AAAA,OAGK;AAEP,SAAS,eAAe,4BAA4B;AACpD,SAAS,2BAAuE;AAwDhF,SAAS,iBAAiB,QAA0B,UAA0B;AAC5E,QAAM,QAAQ,oBAAI,IAAsC;AACxD,SAAO,CAAC,aAA+C;AACrD,QAAI,UAAU,MAAM,IAAI,QAAQ;AAChC,QAAI,CAAC,SAAS;AACZ,gBAAU;AAAA,QACR;AAAA,QACA,OAAO,uBAAuB,QAAQ;AAAA,QACtC,wBAAwB,QAAQ;AAAA,MAClC;AACA,YAAM,IAAI,UAAU,OAAO;AAAA,IAC7B;AACA,WAAO;AAAA,EACT;AACF;AAEA,eAAe,aACb,QACA,UACA,KACuB;AACvB,MAAI,QAAQ,YAAY;AACtB,UAAM,IAAI,cAAc,mEAAmE;AAAA,EAC7F;AACA,MAAI,OAAO,aAAa,aAAa;AACnC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,QAAM,WAAW,OAAO,WAAW,OAAO,OAAO,SAAS,SAAS,IAAI;AACvE,MAAI,CAAC,UAAU;AACb,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO,oBAAkC,UAAU,UAAU,uBAAuB;AACtF;AAGA,MAAM,YAAY;AAAA,EAIhB,YAA6B,MAAoB;AAApB;AAH7B,SAAQ,UAAkC;AAC1C,SAAQ,aAAkC;AAAA,EAEQ;AAAA,EAElD,IAAI,SAAkB;AACpB,QAAI,CAAC,KAAK,QAAS,OAAM,IAAI,cAAc,sCAAsC;AACjF,WAAO,KAAK,QAAQ;AAAA,EACtB;AAAA,EAEA,MAAM,QAAuB;AAC3B,UAAM,KAAK,SAAS,QAAQ;AAAA,EAC9B;AAAA,EAEA,KAAK,SAAoC;AACvC,QAAI,YAAY,KAAK,WAAY;AACjC,SAAK,QAAQ;AACb,QAAI,CAAC,QAAS;AACd,UAAM,SAAS,SAAS,cAAc,KAAK;AAC3C,aAAS,KAAK,YAAY,MAAM;AAChC,SAAK,UAAU,KAAK,KAAK,QAAQ,UAAU,SAAS,MAAM;AAC1D,SAAK,aAAa;AAAA,EACpB;AAAA,EAEA,UAAgB;AACd,SAAK,SAAS,QAAQ;AACtB,SAAK,SAAS,OAAO,OAAO;AAC5B,SAAK,UAAU;AACf,SAAK,aAAa;AAAA,EACpB;AACF;AAEA,SAAS,UAAU,QAAoC;AACrD,QAAM,OAAO,CAAC,aAA8B;AAC1C,UAAM,QAAQ,OAAO,OAAO,cAAc,QAAQ;AAClD,QAAI,CAAC,MAAO,OAAM,IAAI,cAAc,uBAAuB,QAAQ,GAAG;AACtE,WAAO;AAAA,EACT;AACA,SAAO;AAAA,IACL,IAAI,SAAS;AACX,aAAO,OAAO;AAAA,IAChB;AAAA,IACA,MAAM,MAAM,UAAU;AACpB,MAAC,KAAK,QAAQ,EAAkB,MAAM;AACtC,YAAM,OAAO,MAAM;AAAA,IACrB;AAAA,IACA,MAAM,KAAK,UAAU,MAAM;AACzB,YAAM,QAAQ,KAAK,QAAQ;AAC3B,YAAM,MAAM;AACZ,YAAM,QAAQ;AACd,YAAM,cAAc,IAAI,MAAM,SAAS,EAAE,SAAS,KAAK,CAAC,CAAC;AACzD,YAAM,OAAO,MAAM;AAAA,IACrB;AAAA,IACA,MAAM,OAAO,UAAU;AACrB,WAAK,QAAQ,EAAE,cAAc,IAAI,MAAM,UAAU,EAAE,SAAS,MAAM,YAAY,KAAK,CAAC,CAAC;AACrF,YAAM,OAAO,MAAM;AAAA,IACrB;AAAA,IACA,MAAM,KAAK,UAAU;AACnB,aAAO,OAAO,OAAO,cAAc,QAAQ,GAAG,eAAe;AAAA,IAC/D;AAAA,IACA,MAAM,OAAO,UAAU;AACrB,aAAO,OAAO,OAAO,cAAc,QAAQ,MAAM;AAAA,IACnD;AAAA,EACF;AACF;AAEA,eAAsB,kBAAkB,SAAqD;AAC3F,QAAM,EAAE,QAAQ,QAAQ,SAAS,YAAY,UAAU,OAAO,IAAI,IAAI;AACtE,QAAM,SAAS,MAAM,IAAI,YAAY,MAAM,aAAa,QAAQ,UAAU,GAAG,CAAC,IAAI;AAElF,QAAM,eAAe,eAAe;AACpC,QAAM,OAAO,wBAAwB,YAAY;AAEjD,QAAM,WAAW,IAAI,aAAa;AAClC,QAAM,aAAa,IAAI,WAAW;AAClC,QAAM,iBAAiB,IAAI,yBAAyB,QAAQ,OAAO,UAAU,IAAI,mBAAmB,CAAC;AACrG,MAAI,eAA6B,MAAM;AACvC,QAAM,YAAY,IAAI,iBAAiB,gBAAgB,UAAU,MAAM,aAAa,CAAC;AACrF,QAAM,EAAE,OAAO,IAAI,qBAAqB,YAAY,SAAS;AAE7D,QAAM,cAAc;AAGpB,cAAY,iBAAiB,YAAY,MAAM,YAAY,EAAE,QAAQ,OAAO,UAAU,CAAC;AACvF,cAAY,iBAAiB,sBAAsB,MAAM,QAAQ,EAAE,QAAQ,OAAO,UAAU,CAAC;AAE7F,QAAM,iBAAiB,iBAAiB,QAAQ,QAAQ;AACxD,QAAM,WAAW,sBAAsB;AAAA,IACrC;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,mBAAmB,CAAC,aAAa,eAAe,QAAQ;AAAA,IACxD,GAAI,OAAO,qBACP,EAAE,oBAAoB,CAAC,OAAO,MAAM,YAAY,MAAM,mBAAoB,OAAqB,MAAM,OAAO,EAAE,IAC9G,CAAC;AAAA,EACP,CAAC;AACD,iBAAe,MAAM;AACnB,UAAM,UAAU,SAAS,QAAQ;AACjC,WAAO,UAAU,QAAQ,QAAQ,QAAQ,KAAK;AAAA,EAChD;AAEA,QAAM,YAAY,YAA2B;AAC3C,QAAI,CAAC,OAAQ;AACb,WAAO,KAAK,SAAS,QAAQ,CAAC;AAC9B,UAAM,OAAO,MAAM;AAAA,EACrB;AACA,QAAM,SAAS,YAA2B;AACxC,UAAM,SAAS,OAAO;AACtB,QAAI,CAAC,OAAQ;AACb,UAAM,UAAU;AAChB,UAAM,IAAI,QAAc,CAAC,YAAY,WAAW,SAAS,CAAC,CAAC;AAC3D,UAAM,OAAO,MAAM;AAAA,EACrB;AAEA,SAAO;AAAA,IACL;AAAA,IACA,QAAQ,eAAe;AAAA,IACvB;AAAA,IACA,MAAM,KAAK,MAAM,QAAQ,OAAO;AAC9B,YAAM,SAAS,MAAM,SAAS,KAAK;AAAA,QACjC;AAAA,QACA,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,QAC3B,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;AAAA,MAC3B,CAAC;AACD,YAAM,UAAU;AAChB,aAAO;AAAA,IACT;AAAA,IACA,MAAM,SAAS,MAAM,QAAQ,OAAO;AAClC,UAAI,CAAC,SAAS,QAAQ,EAAG,OAAM,IAAI,cAAc,sDAAsD;AACvG,YAAM,SAAS,IAAI,MAAM,WAAW,GAAG,MAAM,QAAQ,KAAK,CAAC;AAC3D,YAAM,UAAU;AAChB,YAAM,UAAU,SAAS,QAAQ;AACjC,UAAI,CAAC,QAAS,OAAM,IAAI,cAAc,aAAa,IAAI,sBAAsB;AAC7E,aAAO;AAAA,IACT;AAAA,IACA,SAAS,MAAM,SAAS,QAAQ;AAAA,IAChC;AAAA,IACA,aAAa,MAAM,SAAS,QAAQ;AAAA,IACpC,KAAK,SAAS,UAAU,MAAM,IAAI;AAAA,IAClC,MAAM,UAAU;AACd,cAAQ,QAAQ;AAChB,YAAM,iBAAiB,IAAI,cAAuB,YAAY;AAG5D,cAAM,SAAS,OAAO,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACtC,cAAM,SAAS,QAAQ;AACvB,cAAM,SAAS,OAAO,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AACtC,cAAM,aAAa,QAAQ;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/testing/wood_env_renderer.ts"],"sourcesContent":["/**\n * The renderer tier of a `testWood(configPath)` environment: a fresh renderer\n * root (mirroring the production process split), an environment-local\n * Navigation, the generated bridge client over an in-process transport into\n * the main router, and the frontend application surface over the selected\n * page catalog. With a DOM mode, the page's real component is mounted by the\n * app's host module and driven through a selector-based surface.\n */\nimport type { IContainer } from '@noego/ioc';\nimport { ExecutionContext, LoadAs } from '@noego/ioc';\nimport type { TestIocBuilder } from '@noego/testing';\nimport type { WoodRouter } from '../router/wood_router.js';\nimport { InProcessRouterTransport } from '../transport/in_process_router_transport.js';\nimport { buildTransportBridge } from '../transport/build_transport_bridge.js';\nimport { RouterBridgeEvents } from './create_router_bridge.js';\nimport type { OperationDef, ViewDef } from '../types/index.js';\nimport { Navigation } from '../navigation/index.js';\nimport type { PageCatalog } from '../frontend/page_catalog.js';\nimport { WorkRegistry } from '../frontend/work_registry.js';\nimport { TrackedTransport } from '../frontend/tracked_transport.js';\nimport {\n createFrontendSurface,\n type FrontendApplicationSurface,\n type PageScopeContext,\n} from '../frontend/frontend_surface.js';\nimport type { FrontendPage } from '../frontend/frontend_page.js';\nimport { TestWoodError, WOOD_FRONTEND_BRIDGE } from './test_wood.js';\nimport { importDefaultExport, type ModuleImporter, type WoodConfigSource } from './wood_config_source.js';\nimport type { WoodDomMode } from './wood_test_selection.js';\nimport { leaseDomAnimation, type WoodAnimationLease } from './wood_dom_animation.js';\nimport {\n buildRecordedEnvironment,\n cleanupAll,\n RegistrationOnlyRoot,\n TestWoodCleanupError,\n} from './registration_only_root.js';\n\n/** What the app's `main.testing.compose` module may hand back for the renderer side. */\nexport interface WoodRendererHooks {\n /** Backend push emitter feeding renderer subscriptions (default: transport-owned). */\n events?: RouterBridgeEvents;\n /** Register app-owned renderer composition on the fresh renderer root. */\n configureRendererRoot?(root: IContainer): void | Promise<void>;\n /** Register per-scope renderer builtins on a new page/layout scope. */\n configurePageScope?(scope: IContainer, view: ViewDef, context: PageScopeContext): void | Promise<void>;\n}\n\n/** A page's real component mounted under a DOM driver (`renderer.testing.host`). */\nexport interface WoodMountedPage {\n readonly target: Element;\n flush?(): void | Promise<void>;\n unmount(): void;\n}\n\nexport type WoodPageHost = (identity: string, page: FrontendPage, target: Element) => WoodMountedPage;\n\nexport interface RendererTierOptions {\n source: WoodConfigSource;\n router: WoodRouter;\n catalog: PageCatalog;\n operations: Map<string, OperationDef>;\n importer: ModuleImporter;\n hooks: WoodRendererHooks | null;\n dom: WoodDomMode | null;\n ioc: TestIocBuilder;\n registrations: RegistrationOnlyRoot;\n controllerClasses: ReadonlyMap<string, ControllerClass>;\n}\n\nexport interface WoodDomDriver {\n readonly target: Element;\n click(selector: string): Promise<void>;\n type(selector: string, text: string): Promise<void>;\n submit(selector: string): Promise<void>;\n text(selector: string): Promise<string>;\n exists(selector: string): Promise<boolean>;\n}\n\nexport interface RendererTier {\n readonly frontend: FrontendApplicationSurface;\n readonly events: RouterBridgeEvents;\n readonly rendererRoot: IContainer;\n open(page: string, params?: Record<string, string>, query?: Record<string, string>): Promise<FrontendPage>;\n navigate(page: string, params?: Record<string, string>, query?: Record<string, string>): Promise<FrontendPage>;\n current(): FrontendPage | null;\n settle(): Promise<void>;\n pendingWork(): ReturnType<WorkRegistry['pending']>;\n verify(): Promise<void>;\n dom: WoodDomDriver | null;\n dispose(): Promise<void>;\n}\ntype ControllerClass = new (...args: never[]) => unknown;\n\nfunction controllerLoader(\n source: WoodConfigSource,\n importer: ModuleImporter,\n preloaded: ReadonlyMap<string, ControllerClass>,\n) {\n const cache = new Map<string, Promise<ControllerClass>>();\n for (const [identity, controller] of preloaded) cache.set(identity, Promise.resolve(controller));\n return (identity: string): Promise<ControllerClass> => {\n let pending = cache.get(identity);\n if (!pending) {\n pending = importDefaultExport<ControllerClass>(\n importer,\n source.rendererControllerFile(identity),\n `renderer controller \"${identity}\"`,\n );\n cache.set(identity, pending);\n }\n return pending;\n };\n}\n\nasync function loadPageHost(\n source: WoodConfigSource,\n importer: ModuleImporter,\n dom: WoodDomMode,\n): Promise<WoodPageHost> {\n if (dom === 'chromium') {\n throw new TestWoodError(\"select({ dom: 'chromium' }) is not available yet; use 'happy-dom'\");\n }\n if (typeof document === 'undefined') {\n throw new TestWoodError(\n \"select({ dom: 'happy-dom' }) requires a DOM environment (// @vitest-environment happy-dom)\",\n );\n }\n const hostPath = source.hookModule(source.config.renderer.testing?.host);\n if (!hostPath) {\n throw new TestWoodError(\n 'select({ dom }) requires renderer.testing.host in wood.config.yml (a module whose default export mounts a page)',\n );\n }\n return importDefaultExport<WoodPageHost>(importer, hostPath, 'renderer.testing.host');\n}\n\n/**\n * Keeps the mounted component in step with the surface's current page. While\n * a page is mounted it holds a Web Animations lease on the target's realm so\n * Svelte transitions work under happy-dom (see `wood_dom_animation.ts`); the\n * lease is released on unmount, on a failed host mount, and on dispose.\n */\nexport class MountKeeper {\n private mounted: WoodMountedPage | null = null;\n private mountedFor: FrontendPage | null = null;\n private animation: WoodAnimationLease | null = null;\n\n constructor(private readonly host: WoodPageHost) {}\n\n get target(): Element {\n if (!this.mounted) throw new TestWoodError('no page is mounted; open(page) first');\n return this.mounted.target;\n }\n\n /** The Web Animations lease held for the current mount (null when nothing is mounted). */\n get animationLease(): WoodAnimationLease | null {\n return this.animation;\n }\n\n async flush(): Promise<void> {\n await this.mounted?.flush?.();\n }\n\n sync(current: FrontendPage | null): void {\n if (current === this.mountedFor) return;\n this.unmount();\n if (!current) return;\n const target = document.createElement('div');\n document.body.appendChild(target);\n const lease = leaseDomAnimation(target);\n try {\n this.mounted = this.host(current.identity, current, target);\n } catch (error) {\n lease.release();\n target.remove();\n throw error;\n }\n this.animation = lease;\n this.mountedFor = current;\n }\n\n unmount(): void {\n const mounted = this.mounted;\n const lease = this.animation;\n this.mounted = null;\n this.mountedFor = null;\n this.animation = null;\n try {\n mounted?.unmount();\n } finally {\n mounted?.target.remove();\n lease?.release();\n }\n }\n}\n\nfunction domDriver(keeper: MountKeeper): WoodDomDriver {\n const find = (selector: string): Element => {\n const found = keeper.target.querySelector(selector);\n if (!found) throw new TestWoodError(`no element matches \"${selector}\"`);\n return found;\n };\n return {\n get target() {\n return keeper.target;\n },\n async click(selector) {\n (find(selector) as HTMLElement).click();\n await keeper.flush();\n },\n async type(selector, text) {\n const input = find(selector) as HTMLInputElement;\n input.focus();\n input.value = text;\n input.dispatchEvent(new Event('input', { bubbles: true }));\n await keeper.flush();\n },\n async submit(selector) {\n find(selector).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));\n await keeper.flush();\n },\n async text(selector) {\n return keeper.target.querySelector(selector)?.textContent ?? '';\n },\n async exists(selector) {\n return keeper.target.querySelector(selector) !== null;\n },\n };\n}\n\nexport async function buildRendererTier(options: RendererTierOptions): Promise<RendererTier> {\n const {\n source,\n router,\n catalog,\n operations,\n importer,\n hooks,\n dom,\n ioc,\n registrations,\n controllerClasses,\n } = options;\n const keeper = dom ? new MountKeeper(await loadPageHost(source, importer, dom)) : null;\n\n const registry = new WorkRegistry();\n const defaultNavigation = new Navigation();\n const innerTransport = new InProcessRouterTransport(router, hooks?.events ?? new RouterBridgeEvents());\n let currentOwner: () => string = () => 'application';\n const transport = new TrackedTransport(innerTransport, registry, () => currentOwner());\n const { bridge } = buildTransportBridge(operations, transport);\n\n registrations.root.registerFunction(Navigation, () => defaultNavigation, { loadAs: LoadAs.Singleton });\n registrations.root.registerFunction(WOOD_FRONTEND_BRIDGE, () => bridge, { loadAs: LoadAs.Singleton });\n\n const rendererEnv = await buildRecordedEnvironment(ioc, registrations, 'renderer');\n const rendererRoot = rendererEnv.root;\n let navigation: Navigation;\n const loadController = controllerLoader(source, importer, controllerClasses);\n let frontend: FrontendApplicationSurface;\n try {\n navigation = await ExecutionContext.run(rendererRoot as never, () => rendererRoot.get(Navigation)) as Navigation;\n frontend = createFrontendSurface({\n catalog,\n rendererRoot: rendererRoot as never,\n registry,\n navigation,\n resolveController: (identity) => loadController(identity),\n ...(hooks?.configurePageScope\n ? { configurePageScope: (scope, view, context) => hooks.configurePageScope!(scope as IContainer, view, context) }\n : {}),\n });\n } catch (error) {\n try {\n await cleanupAll('renderer build rollback failed', [\n ['renderer mount', () => keeper?.unmount()],\n ['renderer root', () => ExecutionContext.run(rendererRoot as never, () => rendererEnv.dispose()) as Promise<void>],\n ]);\n } catch (cleanupError) {\n const failures = cleanupError instanceof TestWoodCleanupError\n ? cleanupError.failures\n : [{ owner: 'renderer rollback', error: cleanupError }];\n throw new TestWoodCleanupError('renderer build failed and rollback failed', failures, error);\n }\n throw error;\n }\n\n currentOwner = () => {\n const current = frontend.current();\n return current ? `page ${current.identity}` : 'application';\n };\n\n const syncMount = async (): Promise<void> => {\n if (!keeper) return;\n keeper.sync(frontend.current());\n await keeper.flush();\n };\n const settle = async (): Promise<void> => {\n await frontend.settle();\n if (!keeper) return;\n await syncMount();\n await new Promise<void>((resolve) => setTimeout(resolve, 0));\n await keeper.flush();\n };\n\n let closing: Promise<void> | null = null;\n const dispose = (): Promise<void> => closing ??= cleanupAll('renderer cleanup failed', [\n ['renderer mount', () => keeper?.unmount()],\n ['renderer work drain (before frontend)', () =>\n ExecutionContext.run(rendererRoot as never, () => frontend.settle()) as Promise<void>],\n ['renderer frontend', () =>\n ExecutionContext.run(rendererRoot as never, () => frontend.dispose()) as Promise<void>],\n ['renderer work drain (after frontend)', () =>\n ExecutionContext.run(rendererRoot as never, () => frontend.settle()) as Promise<void>],\n ['renderer root', () =>\n ExecutionContext.run(rendererRoot as never, () => rendererEnv.dispose()) as Promise<void>],\n ]);\n\n return {\n frontend,\n events: innerTransport.events,\n rendererRoot,\n async open(page, params, query) {\n const opened = await frontend.open({\n page,\n ...(params ? { params } : {}),\n ...(query ? { query } : {}),\n });\n await syncMount();\n return opened;\n },\n async navigate(page, params, query) {\n if (!frontend.current()) throw new TestWoodError('navigate() needs an open page; call open(page) first');\n await frontend.act(() => navigation.go(page, params, query));\n await syncMount();\n const current = frontend.current();\n if (!current) throw new TestWoodError(`navigate(\"${page}\") left no page open`);\n return current;\n },\n current: () => frontend.current(),\n settle,\n pendingWork: () => registry.pending(),\n verify: () => rendererEnv.verify(),\n dom: keeper ? domDriver(keeper) : null,\n dispose,\n };\n}\n"],"mappings":"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","names":[]}
@@ -3,6 +3,9 @@ import { RouterBridgeEvents } from '../testing/create_router_bridge.cjs';
3
3
  import { WoodRpcTransport } from './wood_rpc_transport.cjs';
4
4
  import '../types/operations.cjs';
5
5
  import '../types/context.cjs';
6
+ import '../validation/schema_validator.cjs';
7
+ import '../middleware/middleware_resolver.cjs';
8
+ import '../controller/controller_resolver.cjs';
6
9
 
7
10
  /**
8
11
  * A `WoodRpcTransport` that dispatches straight through a real `WoodRouter`
@@ -3,6 +3,9 @@ import { RouterBridgeEvents } from '../testing/create_router_bridge.js';
3
3
  import { WoodRpcTransport } from './wood_rpc_transport.js';
4
4
  import '../types/operations.js';
5
5
  import '../types/context.js';
6
+ import '../validation/schema_validator.js';
7
+ import '../middleware/middleware_resolver.js';
8
+ import '../controller/controller_resolver.js';
6
9
 
7
10
  /**
8
11
  * A `WoodRpcTransport` that dispatches straight through a real `WoodRouter`
@@ -6,4 +6,7 @@ export { TransportBridgeResult, buildTransportBridge } from './build_transport_b
6
6
  import '../router/wood_router.cjs';
7
7
  import '../types/operations.cjs';
8
8
  import '../types/context.cjs';
9
+ import '../validation/schema_validator.cjs';
10
+ import '../middleware/middleware_resolver.cjs';
11
+ import '../controller/controller_resolver.cjs';
9
12
  import '../testing/create_router_bridge.cjs';
@@ -6,4 +6,7 @@ export { TransportBridgeResult, buildTransportBridge } from './build_transport_b
6
6
  import '../router/wood_router.js';
7
7
  import '../types/operations.js';
8
8
  import '../types/context.js';
9
+ import '../validation/schema_validator.js';
10
+ import '../middleware/middleware_resolver.js';
11
+ import '../controller/controller_resolver.js';
9
12
  import '../testing/create_router_bridge.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/wood",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -23,6 +23,8 @@
23
23
  "src/client/app_env.ts",
24
24
  "src/client/controller_contract.ts",
25
25
  "src/controller/controller_lifecycle.ts",
26
+ "src/controller/cleanup.ts",
27
+ "src/navigation/layout_preservation.ts",
26
28
  "src/navigation/navigation_identity.ts",
27
29
  "src/runtime/app_env.ts"
28
30
  ],
@@ -125,10 +127,10 @@
125
127
  "prepublishOnly": "npm run build && npm run typecheck && npm test"
126
128
  },
127
129
  "dependencies": {
128
- "@noego/ioc": "^0.7.0",
130
+ "@noego/ioc": "^0.8.0",
129
131
  "@noego/logger": "^0.2.0",
130
- "@noego/stitch": "^1.0.3",
131
- "@noego/telemetry": "^0.1.0",
132
+ "@noego/stitch": "^1.1.0",
133
+ "@noego/telemetry": "^0.2.0",
132
134
  "@noego/trace": "^0.2.0",
133
135
  "ajv": "^8.x",
134
136
  "ajv-formats": "^3.x",
@@ -140,7 +142,7 @@
140
142
  "rxjs": "^7.x"
141
143
  },
142
144
  "peerDependencies": {
143
- "@noego/testing": ">=0.5.0",
145
+ "@noego/testing": ">=0.6.0",
144
146
  "@sveltejs/vite-plugin-svelte": "^4.0.0",
145
147
  "electron": ">=28.0.0",
146
148
  "playwright": "^1.40.0",
@@ -158,7 +160,7 @@
158
160
  }
159
161
  },
160
162
  "devDependencies": {
161
- "@noego/testing": "^0.5.0",
163
+ "@noego/testing": "^0.6.0",
162
164
  "@sveltejs/vite-plugin-svelte": "^4.0.4",
163
165
  "@types/better-sqlite3": "^9.6.0",
164
166
  "@types/js-yaml": "^4.x",
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { setContext, onDestroy } from 'svelte';
3
3
  import { getNavigation, type NavigationEntry } from '@noego/wood/navigation';
4
+ import { cleanupAll } from '../controller/cleanup';
4
5
  import { disposeControllerLifecycle } from '../controller/controller_lifecycle';
5
6
  import { createNavigationMountKey } from '../navigation/navigation_identity';
6
7
  import { computePreservedLayoutDepth as computeSharedPreservedLayoutDepth } from '../navigation/layout_preservation';
@@ -60,6 +61,7 @@
60
61
  initialControllerSet,
61
62
  createControllerSetFn,
62
63
  getViewManifestEntryFn,
64
+ onLifecycleError,
63
65
  }: {
64
66
  manifest: Record<string, ViewManifestEntry>;
65
67
  initialEntry: ViewManifestEntry;
@@ -70,6 +72,8 @@
70
72
  initialControllerSet?: ControllerSet;
71
73
  createControllerSetFn?: CreateControllerSetFn;
72
74
  getViewManifestEntryFn: GetViewManifestEntryFn;
75
+ /** Observe navigation/cleanup failures without skipping remaining owners. */
76
+ onLifecycleError?: (error: unknown) => void;
73
77
  } = $props();
74
78
 
75
79
  // Unified controller-set factory: prefer the scope-aware contract; wrap
@@ -222,10 +226,12 @@
222
226
  layoutControllers: Array<unknown>,
223
227
  fromLayoutIndex = 0,
224
228
  ): Promise<void> {
225
- await disposeControllerLifecycle(controller);
226
- for (let index = fromLayoutIndex; index < layoutControllers.length; index += 1) {
227
- await disposeControllerLifecycle(layoutControllers[index]);
228
- }
229
+ try {
230
+ await cleanupAll([
231
+ () => disposeControllerLifecycle(controller),
232
+ ...layoutControllers.slice(fromLayoutIndex).map(layout => () => disposeControllerLifecycle(layout)),
233
+ ], 'Wood controller-set cleanup failed');
234
+ } catch (error) { reportNavigationError('controller-set cleanup failed', error); }
229
235
  }
230
236
 
231
237
  async function disposeActiveControllers(): Promise<void> {
@@ -233,14 +239,13 @@
233
239
  }
234
240
 
235
241
  async function disposeLayoutControllers(layoutControllers: Array<unknown>): Promise<void> {
236
- for (const layoutController of layoutControllers) {
237
- await disposeControllerLifecycle(layoutController);
238
- }
242
+ await cleanupAll(layoutControllers.map(controller => () => disposeControllerLifecycle(controller)), 'Wood layout cleanup failed');
239
243
  }
240
244
 
241
245
  function reportNavigationError(context: string, error: unknown): void {
242
246
  const message = error instanceof Error ? error.message : String(error);
243
247
  console.error(`[wood] ${context}: ${message}`);
248
+ try { onLifecycleError?.(error); } catch (observerError) { console.error('[wood] lifecycle error observer failed', observerError); }
244
249
  }
245
250
 
246
251
  async function disposeStagedControllers(
@@ -0,0 +1,8 @@
1
+ /** Attempt every owner in order, preserving all failures (including non-Error throws). */
2
+ export async function cleanupAll(steps: ReadonlyArray<() => unknown | Promise<unknown>>, message = 'Wood cleanup failed'): Promise<void> {
3
+ const errors: unknown[] = [];
4
+ for (const step of steps) {
5
+ try { await step(); } catch (error) { errors.push(error); }
6
+ }
7
+ if (errors.length) throw Object.assign(new Error(message), { errors, cause: errors[0] });
8
+ }
@@ -13,6 +13,7 @@ export async function disposeControllerLifecycle(target: unknown): Promise<void>
13
13
  return;
14
14
  }
15
15
 
16
+ guardControllerLifecycleOnce(target);
16
17
  const lifecycle = target as ControllerLifecycle;
17
18
  if (typeof lifecycle.dispose === 'function') {
18
19
  await lifecycle.dispose();
@@ -22,3 +23,26 @@ export async function disposeControllerLifecycle(target: unknown): Promise<void>
22
23
  await lifecycle.destroy();
23
24
  }
24
25
  }
26
+
27
+ const guardedControllers = new WeakSet<object>();
28
+
29
+ /** Share one teardown attempt between the explicit controller owner and IoC. */
30
+ export function guardControllerLifecycleOnce(target: unknown): void {
31
+ if (!target || (typeof target !== 'object' && typeof target !== 'function')) return;
32
+ if (guardedControllers.has(target as object)) return;
33
+ guardedControllers.add(target as object);
34
+ const instance = target as Record<string, unknown>;
35
+ for (const name of ['dispose', 'destroy'] as const) {
36
+ const original = instance[name];
37
+ if (typeof original !== 'function') continue;
38
+ let called = false;
39
+ Object.defineProperty(instance, name, {
40
+ configurable: true, writable: true,
41
+ value: function (this: unknown, ...args: unknown[]) {
42
+ if (called) return;
43
+ called = true;
44
+ return original.apply(this, args);
45
+ },
46
+ });
47
+ }
48
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Layout preservation depth — the single production rule for how many
3
+ * leading layouts a navigation transition keeps mounted.
4
+ *
5
+ * One implementation, two callers: the renderer's NavigationShell (real
6
+ * component transitions) and the Node frontend application slice
7
+ * (`createFrontendSurface`). They must agree — a slice test that preserved
8
+ * a layout the renderer rebuilds, or vice versa, would be evidence about a
9
+ * rule that does not exist in production.
10
+ *
11
+ * The rule: walk the layout stacks in parallel and keep a layout only while
12
+ * BOTH sides declare the same layout path, the same layout controller, and
13
+ * both mark it `preserve: true`. `NavigationOptions.preserveLayouts`
14
+ * overrides the declaration in both directions.
15
+ */
16
+ import type { NavigationOptions } from './index.js';
17
+
18
+ /** The layout stack of one view, in the shape both callers can supply. */
19
+ export interface LayoutStack {
20
+ layoutPaths: readonly string[];
21
+ layoutControllers?: ReadonlyArray<string | undefined>;
22
+ layoutPreserve?: readonly boolean[];
23
+ }
24
+
25
+ export function computePreservedLayoutDepth(
26
+ from: LayoutStack,
27
+ to: LayoutStack,
28
+ options: NavigationOptions = {},
29
+ ): number {
30
+ const maxDepth = Math.min(from.layoutPaths.length, to.layoutPaths.length);
31
+ let depth = 0;
32
+ for (let index = 0; index < maxDepth; index += 1) {
33
+ if (from.layoutPaths[index] !== to.layoutPaths[index]) break;
34
+ if (from.layoutControllers?.[index] !== to.layoutControllers?.[index]) break;
35
+ if (options.preserveLayouts === false) break;
36
+ if (
37
+ options.preserveLayouts !== true &&
38
+ (from.layoutPreserve?.[index] !== true || to.layoutPreserve?.[index] !== true)
39
+ ) {
40
+ break;
41
+ }
42
+ depth += 1;
43
+ }
44
+ return depth;
45
+ }