@noego/wood 0.4.5 → 0.4.7

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 (248) hide show
  1. package/bin/wood.js +9 -1
  2. package/dist/client/app_env.cjs +39 -0
  3. package/dist/client/app_env.cjs.map +1 -0
  4. package/dist/client/app_env.d.cts +7 -0
  5. package/dist/client/app_env.d.ts +7 -0
  6. package/dist/client/app_env.js +14 -0
  7. package/dist/client/app_env.js.map +1 -0
  8. package/dist/client/index.cjs +33 -9
  9. package/dist/client/index.cjs.map +1 -1
  10. package/dist/client/index.d.cts +5 -1
  11. package/dist/client/index.d.ts +5 -1
  12. package/dist/client/index.js +32 -9
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/codegen/channel_map_generator.cjs +102 -0
  15. package/dist/codegen/channel_map_generator.cjs.map +1 -0
  16. package/dist/codegen/channel_map_generator.d.cts +6 -0
  17. package/dist/codegen/channel_map_generator.d.ts +6 -0
  18. package/dist/codegen/channel_map_generator.js +78 -0
  19. package/dist/codegen/channel_map_generator.js.map +1 -0
  20. package/dist/codegen/controller_registration_generator.cjs +6 -1
  21. package/dist/codegen/controller_registration_generator.cjs.map +1 -1
  22. package/dist/codegen/controller_registration_generator.js +6 -1
  23. package/dist/codegen/controller_registration_generator.js.map +1 -1
  24. package/dist/codegen/generated_file_notice.cjs +40 -0
  25. package/dist/codegen/generated_file_notice.cjs.map +1 -0
  26. package/dist/codegen/generated_file_notice.d.cts +3 -0
  27. package/dist/codegen/generated_file_notice.d.ts +3 -0
  28. package/dist/codegen/generated_file_notice.js +16 -0
  29. package/dist/codegen/generated_file_notice.js.map +1 -0
  30. package/dist/codegen/index.cjs +3 -0
  31. package/dist/codegen/index.cjs.map +1 -1
  32. package/dist/codegen/index.d.cts +1 -0
  33. package/dist/codegen/index.d.ts +1 -0
  34. package/dist/codegen/index.js +2 -0
  35. package/dist/codegen/index.js.map +1 -1
  36. package/dist/codegen/preload_generator.cjs +97 -1
  37. package/dist/codegen/preload_generator.cjs.map +1 -1
  38. package/dist/codegen/preload_generator.js +97 -1
  39. package/dist/codegen/preload_generator.js.map +1 -1
  40. package/dist/codegen/renderer_app_generator.cjs +6 -1
  41. package/dist/codegen/renderer_app_generator.cjs.map +1 -1
  42. package/dist/codegen/renderer_app_generator.js +6 -1
  43. package/dist/codegen/renderer_app_generator.js.map +1 -1
  44. package/dist/codegen/renderer_controller_registration_generator.cjs +6 -1
  45. package/dist/codegen/renderer_controller_registration_generator.cjs.map +1 -1
  46. package/dist/codegen/renderer_controller_registration_generator.js +6 -1
  47. package/dist/codegen/renderer_controller_registration_generator.js.map +1 -1
  48. package/dist/codegen/renderer_view_manifest_generator.cjs +6 -1
  49. package/dist/codegen/renderer_view_manifest_generator.cjs.map +1 -1
  50. package/dist/codegen/renderer_view_manifest_generator.js +6 -1
  51. package/dist/codegen/renderer_view_manifest_generator.js.map +1 -1
  52. package/dist/codegen/runtime_generator.cjs +6 -1
  53. package/dist/codegen/runtime_generator.cjs.map +1 -1
  54. package/dist/codegen/runtime_generator.js +6 -1
  55. package/dist/codegen/runtime_generator.js.map +1 -1
  56. package/dist/codegen/test_mock_generator.cjs +5 -1
  57. package/dist/codegen/test_mock_generator.cjs.map +1 -1
  58. package/dist/codegen/test_mock_generator.js +5 -1
  59. package/dist/codegen/test_mock_generator.js.map +1 -1
  60. package/dist/codegen/type_generator.cjs +7 -1
  61. package/dist/codegen/type_generator.cjs.map +1 -1
  62. package/dist/codegen/type_generator.js +7 -1
  63. package/dist/codegen/type_generator.js.map +1 -1
  64. package/dist/index.cjs +19 -6
  65. package/dist/index.cjs.map +1 -1
  66. package/dist/index.d.cts +7 -1
  67. package/dist/index.d.ts +7 -1
  68. package/dist/index.js +13 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/main/index.cjs +287 -62
  71. package/dist/main/index.cjs.map +1 -1
  72. package/dist/main/index.d.cts +57 -3
  73. package/dist/main/index.d.ts +57 -3
  74. package/dist/main/index.js +289 -62
  75. package/dist/main/index.js.map +1 -1
  76. package/dist/main/renderer_trace_drain_coordinator.cjs +127 -0
  77. package/dist/main/renderer_trace_drain_coordinator.cjs.map +1 -0
  78. package/dist/main/renderer_trace_drain_coordinator.d.cts +46 -0
  79. package/dist/main/renderer_trace_drain_coordinator.d.ts +46 -0
  80. package/dist/main/renderer_trace_drain_coordinator.js +101 -0
  81. package/dist/main/renderer_trace_drain_coordinator.js.map +1 -0
  82. package/dist/router/wood_router.cjs +9 -1
  83. package/dist/router/wood_router.cjs.map +1 -1
  84. package/dist/router/wood_router.d.cts +1 -1
  85. package/dist/router/wood_router.d.ts +1 -1
  86. package/dist/router/wood_router.js +9 -1
  87. package/dist/router/wood_router.js.map +1 -1
  88. package/dist/runtime/app_env.cjs +52 -0
  89. package/dist/runtime/app_env.cjs.map +1 -0
  90. package/dist/runtime/app_env.d.cts +10 -0
  91. package/dist/runtime/app_env.d.ts +10 -0
  92. package/dist/runtime/app_env.js +18 -0
  93. package/dist/runtime/app_env.js.map +1 -0
  94. package/dist/runtime/index.cjs +9 -2
  95. package/dist/runtime/index.cjs.map +1 -1
  96. package/dist/runtime/index.d.cts +5 -1
  97. package/dist/runtime/index.d.ts +5 -1
  98. package/dist/runtime/index.js +9 -1
  99. package/dist/runtime/index.js.map +1 -1
  100. package/dist/runtime/renderer_log_dispatcher.cjs.map +1 -1
  101. package/dist/runtime/renderer_log_dispatcher.d.cts +1 -1
  102. package/dist/runtime/renderer_log_dispatcher.d.ts +1 -1
  103. package/dist/runtime/renderer_log_dispatcher.js.map +1 -1
  104. package/dist/runtime/renderer_trace_batch_receiver_registry.cjs +50 -0
  105. package/dist/runtime/renderer_trace_batch_receiver_registry.cjs.map +1 -0
  106. package/dist/runtime/renderer_trace_batch_receiver_registry.d.cts +10 -0
  107. package/dist/runtime/renderer_trace_batch_receiver_registry.d.ts +10 -0
  108. package/dist/runtime/renderer_trace_batch_receiver_registry.js +24 -0
  109. package/dist/runtime/renderer_trace_batch_receiver_registry.js.map +1 -0
  110. package/dist/runtime/runtime.cjs +75 -57
  111. package/dist/runtime/runtime.cjs.map +1 -1
  112. package/dist/runtime/runtime.d.cts +8 -1
  113. package/dist/runtime/runtime.d.ts +8 -1
  114. package/dist/runtime/runtime.js +80 -58
  115. package/dist/runtime/runtime.js.map +1 -1
  116. package/dist/testing/browser.cjs +540 -11
  117. package/dist/testing/browser.cjs.map +1 -1
  118. package/dist/testing/browser.d.cts +219 -3
  119. package/dist/testing/browser.d.ts +219 -3
  120. package/dist/testing/browser.js +540 -11
  121. package/dist/testing/browser.js.map +1 -1
  122. package/dist/testing/create_controller_harness.d.cts +1 -1
  123. package/dist/testing/create_controller_harness.d.ts +1 -1
  124. package/dist/testing/index.cjs +13 -0
  125. package/dist/testing/index.cjs.map +1 -1
  126. package/dist/testing/index.d.cts +7 -2
  127. package/dist/testing/index.d.ts +7 -2
  128. package/dist/testing/index.js +14 -0
  129. package/dist/testing/index.js.map +1 -1
  130. package/dist/trace-testing/index.d.cts +1 -1
  131. package/dist/trace-testing/index.d.ts +1 -1
  132. package/dist/trace-testing/trace_recorder.d.cts +1 -1
  133. package/dist/trace-testing/trace_recorder.d.ts +1 -1
  134. package/dist/trace-transport/index.cjs +32 -0
  135. package/dist/trace-transport/index.cjs.map +1 -0
  136. package/dist/trace-transport/index.d.cts +6 -0
  137. package/dist/trace-transport/index.d.ts +6 -0
  138. package/dist/trace-transport/index.js +7 -0
  139. package/dist/trace-transport/index.js.map +1 -0
  140. package/dist/trace_hub-Cob54qRx.d.cts +56 -0
  141. package/dist/trace_hub-Cob54qRx.d.ts +56 -0
  142. package/dist/tracing/index.cjs +14 -0
  143. package/dist/tracing/index.cjs.map +1 -1
  144. package/dist/tracing/index.d.cts +7 -1
  145. package/dist/tracing/index.d.ts +7 -1
  146. package/dist/tracing/index.js +12 -0
  147. package/dist/tracing/index.js.map +1 -1
  148. package/dist/tracing/renderer_trace_batch_host.cjs +80 -0
  149. package/dist/tracing/renderer_trace_batch_host.cjs.map +1 -0
  150. package/dist/tracing/renderer_trace_batch_host.d.cts +16 -0
  151. package/dist/tracing/renderer_trace_batch_host.d.ts +16 -0
  152. package/dist/tracing/renderer_trace_batch_host.js +56 -0
  153. package/dist/tracing/renderer_trace_batch_host.js.map +1 -0
  154. package/dist/tracing/renderer_trace_batch_transport.cjs +313 -0
  155. package/dist/tracing/renderer_trace_batch_transport.cjs.map +1 -0
  156. package/dist/tracing/renderer_trace_batch_transport.d.cts +53 -0
  157. package/dist/tracing/renderer_trace_batch_transport.d.ts +53 -0
  158. package/dist/tracing/renderer_trace_batch_transport.js +292 -0
  159. package/dist/tracing/renderer_trace_batch_transport.js.map +1 -0
  160. package/dist/tracing/trace_commit_writer.cjs +159 -0
  161. package/dist/tracing/trace_commit_writer.cjs.map +1 -0
  162. package/dist/tracing/trace_commit_writer.d.cts +97 -0
  163. package/dist/tracing/trace_commit_writer.d.ts +97 -0
  164. package/dist/tracing/trace_commit_writer.js +135 -0
  165. package/dist/tracing/trace_commit_writer.js.map +1 -0
  166. package/dist/tracing/trace_hub.cjs +58 -3
  167. package/dist/tracing/trace_hub.cjs.map +1 -1
  168. package/dist/tracing/trace_hub.d.cts +2 -46
  169. package/dist/tracing/trace_hub.d.ts +2 -46
  170. package/dist/tracing/trace_hub.js +56 -3
  171. package/dist/tracing/trace_hub.js.map +1 -1
  172. package/dist/tracing/trace_provider.cjs +42 -12
  173. package/dist/tracing/trace_provider.cjs.map +1 -1
  174. package/dist/tracing/trace_provider.d.cts +15 -1
  175. package/dist/tracing/trace_provider.d.ts +15 -1
  176. package/dist/tracing/trace_provider.js +43 -12
  177. package/dist/tracing/trace_provider.js.map +1 -1
  178. package/dist/tracing/types/wood_trace_batch.types.cjs +17 -0
  179. package/dist/tracing/types/wood_trace_batch.types.cjs.map +1 -0
  180. package/dist/tracing/types/wood_trace_batch.types.d.cts +124 -0
  181. package/dist/tracing/types/wood_trace_batch.types.d.ts +124 -0
  182. package/dist/tracing/types/wood_trace_batch.types.js +1 -0
  183. package/dist/tracing/types/wood_trace_batch.types.js.map +1 -0
  184. package/dist/tracing/types/wood_trace_sink.types.cjs +17 -0
  185. package/dist/tracing/types/wood_trace_sink.types.cjs.map +1 -0
  186. package/dist/tracing/types/wood_trace_sink.types.d.cts +2 -0
  187. package/dist/tracing/types/wood_trace_sink.types.d.ts +2 -0
  188. package/dist/tracing/types/wood_trace_sink.types.js +1 -0
  189. package/dist/tracing/types/wood_trace_sink.types.js.map +1 -0
  190. package/dist/tracing/wood_trace_batch_contract.cjs +229 -0
  191. package/dist/tracing/wood_trace_batch_contract.cjs.map +1 -0
  192. package/dist/tracing/wood_trace_batch_contract.d.cts +23 -0
  193. package/dist/tracing/wood_trace_batch_contract.d.ts +23 -0
  194. package/dist/tracing/wood_trace_batch_contract.js +205 -0
  195. package/dist/tracing/wood_trace_batch_contract.js.map +1 -0
  196. package/dist/transport/build_transport_bridge.cjs +62 -0
  197. package/dist/transport/build_transport_bridge.cjs.map +1 -0
  198. package/dist/transport/build_transport_bridge.d.cts +29 -0
  199. package/dist/transport/build_transport_bridge.d.ts +29 -0
  200. package/dist/transport/build_transport_bridge.js +38 -0
  201. package/dist/transport/build_transport_bridge.js.map +1 -0
  202. package/dist/transport/in_process_router_transport.cjs +41 -0
  203. package/dist/transport/in_process_router_transport.cjs.map +1 -0
  204. package/dist/transport/in_process_router_transport.d.cts +31 -0
  205. package/dist/transport/in_process_router_transport.d.ts +31 -0
  206. package/dist/transport/in_process_router_transport.js +17 -0
  207. package/dist/transport/in_process_router_transport.js.map +1 -0
  208. package/dist/transport/index.cjs +42 -0
  209. package/dist/transport/index.cjs.map +1 -0
  210. package/dist/transport/index.d.cts +8 -0
  211. package/dist/transport/index.d.ts +8 -0
  212. package/dist/transport/index.js +19 -0
  213. package/dist/transport/index.js.map +1 -0
  214. package/dist/transport/loopback_remote_transport.cjs +270 -0
  215. package/dist/transport/loopback_remote_transport.cjs.map +1 -0
  216. package/dist/transport/loopback_remote_transport.d.cts +49 -0
  217. package/dist/transport/loopback_remote_transport.d.ts +49 -0
  218. package/dist/transport/loopback_remote_transport.js +237 -0
  219. package/dist/transport/loopback_remote_transport.js.map +1 -0
  220. package/dist/transport/loopback_serialization.cjs +116 -0
  221. package/dist/transport/loopback_serialization.cjs.map +1 -0
  222. package/dist/transport/loopback_serialization.d.cts +28 -0
  223. package/dist/transport/loopback_serialization.d.ts +28 -0
  224. package/dist/transport/loopback_serialization.js +91 -0
  225. package/dist/transport/loopback_serialization.js.map +1 -0
  226. package/dist/transport/transport_contract_suite.cjs +110 -0
  227. package/dist/transport/transport_contract_suite.cjs.map +1 -0
  228. package/dist/transport/transport_contract_suite.d.cts +23 -0
  229. package/dist/transport/transport_contract_suite.d.ts +23 -0
  230. package/dist/transport/transport_contract_suite.js +85 -0
  231. package/dist/transport/transport_contract_suite.js.map +1 -0
  232. package/dist/transport/wood_rpc_transport.cjs +17 -0
  233. package/dist/transport/wood_rpc_transport.cjs.map +1 -0
  234. package/dist/transport/wood_rpc_transport.d.cts +44 -0
  235. package/dist/transport/wood_rpc_transport.d.ts +44 -0
  236. package/dist/transport/wood_rpc_transport.js +1 -0
  237. package/dist/transport/wood_rpc_transport.js.map +1 -0
  238. package/dist/types/config.cjs.map +1 -1
  239. package/dist/types/config.d.cts +12 -0
  240. package/dist/types/config.d.ts +12 -0
  241. package/dist/window/window_manager.cjs +3 -0
  242. package/dist/window/window_manager.cjs.map +1 -1
  243. package/dist/window/window_manager.js +3 -0
  244. package/dist/window/window_manager.js.map +1 -1
  245. package/docs/browser-testing.md +139 -1
  246. package/package.json +22 -3
  247. package/src/components/NavigationShell.browser.test.ts +236 -0
  248. package/src/components/NavigationShell.svelte +162 -49
@@ -0,0 +1,236 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, test } from 'vitest';
5
+ import { browser } from '../testing/browser';
6
+
7
+ type ReproSnapshot = {
8
+ pageCreateCount: number;
9
+ layoutCreateCount: number;
10
+ disposeCounts: Record<string, number>;
11
+ rendered: string;
12
+ pageErrors: string[];
13
+ };
14
+
15
+ describe('NavigationShell failed transition recovery', () => {
16
+ const harnesses: Array<{ destroy(): Promise<void> }> = [];
17
+ const tempRoots: string[] = [];
18
+
19
+ afterEach(async () => {
20
+ while (harnesses.length > 0) {
21
+ await harnesses.pop()?.destroy();
22
+ }
23
+ while (tempRoots.length > 0) {
24
+ rmSync(tempRoots.pop()!, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ test('does not orphan a controller when a sibling layout creation rejects', async () => {
29
+ const woodRoot = path.resolve(__dirname, '../..');
30
+ const rootDir = mkdtempSync(path.join(os.tmpdir(), 'wood-navigation-shell-repro-'));
31
+ tempRoots.push(rootDir);
32
+ mkdirSync(path.join(rootDir, 'ui'), { recursive: true });
33
+
34
+ writeFileSync(
35
+ path.join(rootDir, 'ui', 'ReproView.svelte'),
36
+ `<script lang="ts">
37
+ let { data = {}, page = {} } = $props<{
38
+ data?: { id?: string };
39
+ page?: { params?: { threadId?: string } };
40
+ }>();
41
+ </script>
42
+
43
+ <main data-repro-view>
44
+ thread={page.params?.threadId ?? 'missing'};controller={data.id ?? 'missing'}
45
+ </main>`,
46
+ );
47
+
48
+ writeFileSync(
49
+ path.join(rootDir, 'ui', 'ReproLayout.svelte'),
50
+ `<script lang="ts">
51
+ import type { Snippet } from 'svelte';
52
+ let { children }: { children: Snippet } = $props();
53
+ </script>
54
+
55
+ <section data-repro-layout>{@render children()}</section>`,
56
+ );
57
+
58
+ writeFileSync(
59
+ path.join(rootDir, 'ui', 'NavigationShellRepro.svelte'),
60
+ `<script lang="ts">
61
+ import '/@fs/${woodRoot}/src/client/global_polyfill.ts';
62
+ import NavigationShell from '/@fs/${woodRoot}/src/components/NavigationShell.svelte';
63
+ import { getNavigation } from '/@fs/${woodRoot}/src/navigation/index.ts';
64
+ import ReproLayout from './ReproLayout.svelte';
65
+ import ReproView from './ReproView.svelte';
66
+
67
+ type ReproState = {
68
+ pageCreateCount: number;
69
+ layoutCreateCount: number;
70
+ disposeCounts: Record<string, number>;
71
+ };
72
+
73
+ const state: ReproState = {
74
+ pageCreateCount: 0,
75
+ layoutCreateCount: 0,
76
+ disposeCounts: {},
77
+ };
78
+ (window as unknown as { __WOOD_NAVIGATION_REPRO__: ReproState })
79
+ .__WOOD_NAVIGATION_REPRO__ = state;
80
+
81
+ function createController(id: string) {
82
+ state.disposeCounts[id] = 0;
83
+ return {
84
+ data: { id },
85
+ dispose() {
86
+ state.disposeCounts[id] += 1;
87
+ },
88
+ };
89
+ }
90
+
91
+ const entry = {
92
+ key: 'main.chat_dom',
93
+ windowName: 'main',
94
+ pageName: 'chat_dom',
95
+ layoutPaths: ['ui/ReproLayout.svelte'],
96
+ layoutControllers: ['repro-layout-controller'],
97
+ layoutPreserve: [false],
98
+ view: ReproView,
99
+ layouts: [ReproLayout],
100
+ };
101
+ const manifest = { [entry.key]: entry };
102
+ const initialController = createController('initial-page');
103
+ const initialLayoutControllers = [createController('initial-layout')];
104
+
105
+ async function createViewControllerFn() {
106
+ state.pageCreateCount += 1;
107
+ return createController(\`page-\${state.pageCreateCount}\`);
108
+ }
109
+
110
+ async function createLayoutControllersFn() {
111
+ state.layoutCreateCount += 1;
112
+ if (state.layoutCreateCount === 1) {
113
+ await Promise.resolve();
114
+ throw new Error('controlled layout creation failure');
115
+ }
116
+ return [createController(\`layout-\${state.layoutCreateCount}\`)];
117
+ }
118
+
119
+ function getViewManifestEntryFn(viewKey: string) {
120
+ return manifest[viewKey as keyof typeof manifest] ?? null;
121
+ }
122
+
123
+ const navigation = getNavigation();
124
+ navigation.replace(entry.key, { threadId: '1' }, {});
125
+ (window as unknown as {
126
+ __WOOD_NAVIGATION_REPRO_GO__: (threadId: string) => void;
127
+ }).__WOOD_NAVIGATION_REPRO_GO__ = (threadId: string) => {
128
+ navigation.go(entry.key, { threadId }, {});
129
+ };
130
+ </script>
131
+
132
+ <NavigationShell
133
+ {manifest}
134
+ initialEntry={entry}
135
+ {initialController}
136
+ {initialLayoutControllers}
137
+ {createViewControllerFn}
138
+ {createLayoutControllersFn}
139
+ {getViewManifestEntryFn}
140
+ />`,
141
+ );
142
+
143
+ const pageErrors: string[] = [];
144
+ const consoleErrors: string[] = [];
145
+ const harness = await browser.createHarness({
146
+ rootDir: woodRoot,
147
+ browser: { headless: true },
148
+ page: {
149
+ beforeMount({ page }) {
150
+ page.on('pageerror', (error: Error) => pageErrors.push(error.message));
151
+ page.on('console', (message: { type(): string; text(): string }) => {
152
+ if (message.type() === 'error') consoleErrors.push(message.text());
153
+ });
154
+ },
155
+ },
156
+ });
157
+ harnesses.push(harness);
158
+ const mounted = await harness.mountComponent(
159
+ path.join(rootDir, 'ui', 'NavigationShellRepro.svelte'),
160
+ );
161
+ await mounted.locator('[data-repro-view]').waitFor({ state: 'visible', timeout: 5_000 }).catch(
162
+ async (error: unknown) => {
163
+ throw new Error(
164
+ `${error instanceof Error ? error.message : String(error)}\n` +
165
+ `Page errors: ${pageErrors.join(' | ')}\n` +
166
+ `Console errors: ${consoleErrors.join(' | ')}\n` +
167
+ `HTML: ${await mounted.page.content()}`,
168
+ );
169
+ },
170
+ );
171
+
172
+ await expect.poll(async () => {
173
+ return (await mounted.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
174
+ }).toBe('thread=1;controller=initial-page');
175
+
176
+ await mounted.evaluate(() => {
177
+ (window as unknown as {
178
+ __WOOD_NAVIGATION_REPRO_GO__: (threadId: string) => void;
179
+ }).__WOOD_NAVIGATION_REPRO_GO__('2');
180
+ });
181
+ await expect.poll(async () => {
182
+ return await mounted.evaluate(() => {
183
+ return (window as unknown as {
184
+ __WOOD_NAVIGATION_REPRO__: { layoutCreateCount: number };
185
+ }).__WOOD_NAVIGATION_REPRO__.layoutCreateCount;
186
+ });
187
+ }).toBe(1);
188
+ await expect.poll(() => pageErrors.length + consoleErrors.length).toBeGreaterThan(0);
189
+
190
+ await mounted.evaluate(() => {
191
+ (window as unknown as {
192
+ __WOOD_NAVIGATION_REPRO_GO__: (threadId: string) => void;
193
+ }).__WOOD_NAVIGATION_REPRO_GO__('2');
194
+ });
195
+ await expect.poll(async () => {
196
+ return await mounted.evaluate(() => {
197
+ return (window as unknown as {
198
+ __WOOD_NAVIGATION_REPRO__: { pageCreateCount: number };
199
+ }).__WOOD_NAVIGATION_REPRO__.pageCreateCount;
200
+ });
201
+ }).toBe(2);
202
+ await expect.poll(async () => {
203
+ return (await mounted.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
204
+ }).toBe('thread=2;controller=page-2');
205
+
206
+ const snapshot = await mounted.evaluate<ReproSnapshot>(() => {
207
+ const state = (window as unknown as {
208
+ __WOOD_NAVIGATION_REPRO__: Omit<ReproSnapshot, 'rendered' | 'pageErrors'>;
209
+ }).__WOOD_NAVIGATION_REPRO__;
210
+ return {
211
+ ...state,
212
+ disposeCounts: { ...state.disposeCounts },
213
+ rendered: document.querySelector('[data-repro-view]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
214
+ pageErrors: [],
215
+ };
216
+ });
217
+ snapshot.pageErrors = [...pageErrors];
218
+
219
+ expect(
220
+ snapshot,
221
+ 'A failed transition must preserve committed controllers and dispose any staged controller before a retry.',
222
+ ).toEqual({
223
+ pageCreateCount: 2,
224
+ layoutCreateCount: 2,
225
+ disposeCounts: {
226
+ 'initial-page': 1,
227
+ 'initial-layout': 1,
228
+ 'page-1': 1,
229
+ 'page-2': 0,
230
+ 'layout-2': 0,
231
+ },
232
+ rendered: 'thread=2;controller=page-2',
233
+ pageErrors: [],
234
+ });
235
+ }, 30_000);
236
+ });
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
2
  import { setContext, onDestroy } from 'svelte';
3
- import { getNavigation } from '@noego/wood/navigation';
3
+ import { getNavigation, type NavigationEntry } from '@noego/wood/navigation';
4
4
  import { disposeControllerLifecycle } from '../controller/controller_lifecycle';
5
5
  import { createNavigationMountKey } from '../navigation/navigation_identity';
6
6
  import WoodRecursiveRender from './WoodRecursiveRender.svelte';
7
+ import { getWoodAppEnv } from '../client/app_env';
7
8
 
8
9
  type ViewManifestEntry = {
9
10
  key: string;
@@ -74,6 +75,8 @@
74
75
  createLayoutMountKey(getInitialEntry(), index, activePageKey),
75
76
  ),
76
77
  );
78
+ let navigationTransitionQueue: Promise<void> = Promise.resolve();
79
+ let destroyed = false;
77
80
 
78
81
  let activePage = $derived({
79
82
  key: activeEntry.key,
@@ -84,6 +87,7 @@
84
87
  });
85
88
 
86
89
  const navigation = getNavigation();
90
+ const appEnv = getWoodAppEnv();
87
91
 
88
92
  function getWoodBridge(): WoodBridgeLike | null {
89
93
  if (typeof window === 'undefined') {
@@ -202,20 +206,74 @@
202
206
  return depth;
203
207
  }
204
208
 
209
+ async function disposeControllerSet(
210
+ controller: unknown,
211
+ layoutControllers: Array<unknown>,
212
+ fromLayoutIndex = 0,
213
+ ): Promise<void> {
214
+ await disposeControllerLifecycle(controller);
215
+ for (let index = fromLayoutIndex; index < layoutControllers.length; index += 1) {
216
+ await disposeControllerLifecycle(layoutControllers[index]);
217
+ }
218
+ }
219
+
205
220
  async function disposeActiveControllers(): Promise<void> {
206
- await disposeControllerLifecycle(activeController);
207
- for (const layoutController of activeLayoutControllers) {
221
+ await disposeControllerSet(activeController, activeLayoutControllers);
222
+ }
223
+
224
+ async function disposeLayoutControllers(layoutControllers: Array<unknown>): Promise<void> {
225
+ for (const layoutController of layoutControllers) {
208
226
  await disposeControllerLifecycle(layoutController);
209
227
  }
210
228
  }
211
229
 
212
- async function disposeChangedControllers(fromLayoutIndex: number): Promise<void> {
213
- await disposeControllerLifecycle(activeController);
214
- for (let index = fromLayoutIndex; index < activeLayoutControllers.length; index += 1) {
215
- await disposeControllerLifecycle(activeLayoutControllers[index]);
230
+ function reportNavigationError(context: string, error: unknown): void {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ console.error(`[wood] ${context}: ${message}`);
233
+ }
234
+
235
+ async function disposeStagedControllers(
236
+ controller: unknown,
237
+ layoutControllers: Array<unknown>,
238
+ ): Promise<void> {
239
+ const cleanupResults = await Promise.allSettled([
240
+ disposeControllerLifecycle(controller),
241
+ disposeLayoutControllers(layoutControllers),
242
+ ]);
243
+ for (const result of cleanupResults) {
244
+ if (result.status === 'rejected') {
245
+ reportNavigationError('navigation transition cleanup failed', result.reason);
246
+ }
216
247
  }
217
248
  }
218
249
 
250
+ async function createTransitionControllers(
251
+ entry: ViewManifestEntry,
252
+ preservedDepth: number,
253
+ ): Promise<{ controller: unknown; layoutControllers: Array<unknown> }> {
254
+ const [controllerResult, layoutControllersResult] = await Promise.allSettled([
255
+ createViewControllerFn(entry),
256
+ createLayoutControllersFn(entry, preservedDepth),
257
+ ]);
258
+
259
+ if (controllerResult.status === 'fulfilled' && layoutControllersResult.status === 'fulfilled') {
260
+ return {
261
+ controller: controllerResult.value,
262
+ layoutControllers: layoutControllersResult.value,
263
+ };
264
+ }
265
+
266
+ await disposeStagedControllers(
267
+ controllerResult.status === 'fulfilled' ? controllerResult.value : undefined,
268
+ layoutControllersResult.status === 'fulfilled' ? layoutControllersResult.value : [],
269
+ );
270
+
271
+ if (controllerResult.status === 'rejected') {
272
+ throw controllerResult.reason;
273
+ }
274
+ throw layoutControllersResult.reason;
275
+ }
276
+
219
277
  function mergeLayoutControllers(
220
278
  preservedDepth: number,
221
279
  createdControllers: Array<unknown>,
@@ -230,14 +288,69 @@
230
288
  return merged;
231
289
  }
232
290
 
291
+ async function applyNavigationEntry(navEntry: NavigationEntry | null): Promise<void> {
292
+ if (destroyed || !navEntry) return;
293
+
294
+ const newKey = navEntry.page;
295
+ const entry = getViewManifestEntryFn(newKey);
296
+ if (!entry) return;
297
+
298
+ const nextPageKey = createNavigationMountKey(newKey, navEntry.params);
299
+
300
+ if (nextPageKey === activePageKey) {
301
+ activeParams = { ...navEntry.params };
302
+ activeQuery = { ...navEntry.query };
303
+ return;
304
+ }
305
+
306
+ const preservedDepth = computePreservedLayoutDepth(
307
+ activeEntry,
308
+ entry,
309
+ navEntry.options ?? {},
310
+ );
311
+ const preservedLayoutKeys = activeLayoutKeys.slice(0, preservedDepth);
312
+ const previousController = activeController;
313
+ const previousLayoutControllers = [...activeLayoutControllers];
314
+ const { controller, layoutControllers } = await createTransitionControllers(entry, preservedDepth);
315
+
316
+ if (destroyed) {
317
+ await disposeStagedControllers(controller, layoutControllers);
318
+ return;
319
+ }
320
+
321
+ const nextLayoutControllers = mergeLayoutControllers(
322
+ preservedDepth,
323
+ layoutControllers,
324
+ entry.layoutPaths.length,
325
+ );
326
+ const nextLayoutKeys = createLayoutMountKeys(entry, nextPageKey, preservedLayoutKeys);
327
+
328
+ activeEntry = entry;
329
+ activeController = controller;
330
+ activeLayoutControllers = nextLayoutControllers;
331
+ activeParams = { ...navEntry.params };
332
+ activeQuery = { ...navEntry.query };
333
+ activePageKey = nextPageKey;
334
+ activeLayoutKeys = nextLayoutKeys;
335
+
336
+ await disposeControllerSet(previousController, previousLayoutControllers, preservedDepth);
337
+ }
338
+
339
+ function enqueueNavigationEntry(navEntry: NavigationEntry | null): void {
340
+ navigationTransitionQueue = navigationTransitionQueue
341
+ .then(() => applyNavigationEntry(navEntry))
342
+ .catch((error: unknown) => {
343
+ reportNavigationError('navigation transition failed', error);
344
+ });
345
+ }
346
+
233
347
  setContext('wood:navigate', (
234
348
  viewKey: string,
235
349
  params?: Record<string, unknown>,
236
350
  options?: { replace?: boolean; preserveLayouts?: boolean },
237
351
  ) => {
238
352
  void navigateWithinCurrentWindow(viewKey, params, options).catch((error) => {
239
- const message = error instanceof Error ? error.message : String(error);
240
- console.error(`[wood] navigation failed: ${message}`);
353
+ reportNavigationError('navigation failed', error);
241
354
  });
242
355
  });
243
356
 
@@ -256,7 +369,7 @@
256
369
 
257
370
  let initialFire = true;
258
371
 
259
- const unsubscribe = navigation.subscribe(async (navEntry) => {
372
+ const unsubscribe = navigation.subscribe((navEntry) => {
260
373
  if (initialFire) {
261
374
  initialFire = false;
262
375
  if (navEntry) {
@@ -268,44 +381,7 @@
268
381
  return;
269
382
  }
270
383
 
271
- if (!navEntry) return;
272
-
273
- const newKey = navEntry.page;
274
- const entry = getViewManifestEntryFn(newKey);
275
- if (!entry) return;
276
-
277
- const nextPageKey = createNavigationMountKey(newKey, navEntry.params);
278
-
279
- if (nextPageKey === activePageKey) {
280
- activeParams = { ...navEntry.params };
281
- activeQuery = { ...navEntry.query };
282
- return;
283
- }
284
-
285
- const preservedDepth = computePreservedLayoutDepth(
286
- activeEntry,
287
- entry,
288
- navEntry.options ?? {},
289
- );
290
- const preservedLayoutKeys = activeLayoutKeys.slice(0, preservedDepth);
291
-
292
- await disposeChangedControllers(preservedDepth);
293
- const [controller, layoutControllers] = await Promise.all([
294
- createViewControllerFn(entry),
295
- createLayoutControllersFn(entry, preservedDepth),
296
- ]);
297
-
298
- activeEntry = entry;
299
- activeController = controller;
300
- activeLayoutControllers = mergeLayoutControllers(
301
- preservedDepth,
302
- layoutControllers,
303
- entry.layoutPaths.length,
304
- );
305
- activeParams = { ...navEntry.params };
306
- activeQuery = { ...navEntry.query };
307
- activePageKey = nextPageKey;
308
- activeLayoutKeys = createLayoutMountKeys(entry, nextPageKey, preservedLayoutKeys);
384
+ enqueueNavigationEntry(navEntry);
309
385
  });
310
386
 
311
387
  const bridge = getWoodBridge();
@@ -328,9 +404,14 @@
328
404
  });
329
405
 
330
406
  onDestroy(() => {
407
+ destroyed = true;
331
408
  unsubscribe();
332
409
  unsubscribeWindowNavigate?.();
333
- void disposeActiveControllers();
410
+ void navigationTransitionQueue
411
+ .then(() => disposeActiveControllers())
412
+ .catch((error: unknown) => {
413
+ reportNavigationError('navigation teardown failed', error);
414
+ });
334
415
  });
335
416
  </script>
336
417
 
@@ -345,3 +426,35 @@
345
426
  query={activeQuery}
346
427
  page={activePage}
347
428
  />
429
+
430
+ {#if appEnv !== 'packaged'}
431
+ <div class="wood-env-ribbon" class:wood-env-ribbon--local={appEnv === 'local'} aria-hidden="true">
432
+ {appEnv === 'dev' ? 'DEBUG' : 'LOCAL'}
433
+ </div>
434
+ {/if}
435
+
436
+ <style>
437
+ .wood-env-ribbon {
438
+ position: absolute;
439
+ top: 22px;
440
+ right: -34px;
441
+ z-index: 2147483647;
442
+ pointer-events: none;
443
+ transform: rotate(45deg);
444
+ width: 140px;
445
+ text-align: center;
446
+ padding: 3px 0;
447
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
448
+ font-size: 11px;
449
+ font-weight: 700;
450
+ letter-spacing: 2px;
451
+ color: #fff;
452
+ background: #b71c1c;
453
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
454
+ user-select: none;
455
+ }
456
+
457
+ .wood-env-ribbon--local {
458
+ background: #1b7f3a;
459
+ }
460
+ </style>