proteum 2.5.25 → 2.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.
@@ -0,0 +1,857 @@
1
+ const assert = require('node:assert/strict');
2
+ const Module = require('node:module');
3
+ const path = require('node:path');
4
+
5
+ const coreRoot = path.join(__dirname, '..');
6
+ require('module-alias').addAliases({
7
+ '@client': path.join(coreRoot, 'client'),
8
+ '@common': path.join(coreRoot, 'common'),
9
+ '@server': path.join(coreRoot, 'server'),
10
+ });
11
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
12
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
13
+ require('ts-node/register/transpile-only');
14
+
15
+ // Page.tsx renders through preact, like the apps that alias react to preact/compat
16
+ let pageContext = {};
17
+ const clientContextStub = () => pageContext;
18
+ clientContextStub.default = clientContextStub;
19
+ const originalLoad = Module._load;
20
+ Module._load = function load(request, parent, isMain) {
21
+ if (request === '@/client/context') return clientContextStub;
22
+ if (request === 'react') return originalLoad.call(this, 'preact/compat', parent, isMain);
23
+
24
+ return originalLoad.call(this, request, parent, isMain);
25
+ };
26
+
27
+ const { h } = require('preact');
28
+ const { renderToString } = require('preact-render-to-string');
29
+ const { createNavigationSequencer, shouldDeferNavigation } = require('../client/services/router/navigation.ts');
30
+ const { createPageNavigation } = require('../common/router/response/page.ts');
31
+ const PageComponent = require('../client/services/router/components/Page.tsx').default;
32
+ const ClientPage = require('../client/services/router/response/page.ts').default;
33
+ // After Page.tsx, which keeps the context stub above: the harness brings window, history and the router
34
+ const routerHarness = require('./clientRouterHarness.cjs');
35
+ const { createRouterNavigation } = require('../client/services/router/components/router.tsx');
36
+
37
+ /*----------------------------------
38
+ - HARNESS
39
+ ----------------------------------*/
40
+
41
+ const createDeferred = () => {
42
+ let resolve;
43
+ let reject;
44
+ const promise = new Promise((resolvePromise, rejectPromise) => {
45
+ resolve = resolvePromise;
46
+ reject = rejectPromise;
47
+ });
48
+
49
+ return { promise, resolve, reject };
50
+ };
51
+
52
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
53
+
54
+ // A page whose every data fetch waits for the test to settle it
55
+ const createPage = (chunkId, { defers = false, prepareError } = {}) => {
56
+ const fetches = [];
57
+ return {
58
+ chunkId,
59
+ defers,
60
+ prepareError,
61
+ data: {},
62
+ navigation: createPageNavigation({ status: 'ready' }),
63
+ fetches,
64
+ nextFetch: () => {
65
+ const fetch = createDeferred();
66
+ fetches.push(fetch);
67
+ return fetch.promise;
68
+ },
69
+ };
70
+ };
71
+
72
+ // A request whose route chunk loads when the test says so
73
+ const createRequest = (page, chunk = Promise.resolve()) => ({ page, chunk });
74
+
75
+ const createHarness = (initialPage) => {
76
+ const log = [];
77
+ let committed = initialPage;
78
+ const ports = {
79
+ resolve: async (request, isCurrent) => {
80
+ log.push(`resolve:${request.page.chunkId}`);
81
+ await request.chunk;
82
+ return isCurrent() ? request.page : null;
83
+ },
84
+ defers: (page) => page.defers,
85
+ prepare: (page) => {
86
+ log.push(`prepare:${page.chunkId}`);
87
+ if (page.prepareError) throw page.prepareError;
88
+ page.onPrepare?.();
89
+ },
90
+ fetch: async (page, prepared) => {
91
+ log.push(`fetch:${page.chunkId}:${prepared ? 'prepared' : 'fresh'}`);
92
+ const data = await page.nextFetch();
93
+ page.data = { ...page.data, ...data };
94
+ },
95
+ commit: (page) => {
96
+ log.push(`commit:${page.chunkId}`);
97
+ committed = page;
98
+ },
99
+ render: (page, data) => log.push(`render:${page.chunkId}:${page.navigation.status}${data ? `:${data}` : ''}`),
100
+ ready: (page) => log.push(`ready:${page.chunkId}`),
101
+ setLoading: (loading) => log.push(`loading:${loading}`),
102
+ fail: (step, error) => log.push(`fail:${step}:${error.message}`),
103
+ startRender: () => log.push('startRender'),
104
+ };
105
+
106
+ return { log, committed: () => committed, sequencer: createNavigationSequencer(ports, initialPage) };
107
+ };
108
+
109
+ /*----------------------------------
110
+ - MODE
111
+ ----------------------------------*/
112
+
113
+ test('a page defers only in deferred mode, when it declares it and has a data loader', () => {
114
+ const loader = () => ({});
115
+ const deferredRoute = { options: { navigation: 'deferred' }, data: loader };
116
+
117
+ assert.equal(shouldDeferNavigation('deferred', deferredRoute), true);
118
+ assert.equal(shouldDeferNavigation('blocking', deferredRoute), false);
119
+ assert.equal(shouldDeferNavigation(undefined, deferredRoute), false);
120
+ assert.equal(shouldDeferNavigation('deferred', { options: {}, data: loader }), false);
121
+ assert.equal(shouldDeferNavigation('deferred', { options: { navigation: 'blocking' }, data: loader }), false);
122
+ assert.equal(shouldDeferNavigation('deferred', { options: { navigation: 'deferred' }, data: null }), false);
123
+ assert.equal(shouldDeferNavigation('deferred', { options: { navigation: 'deferred' } }), false);
124
+ });
125
+
126
+ /*----------------------------------
127
+ - BLOCKING
128
+ ----------------------------------*/
129
+
130
+ test('blocking navigation keeps the loader, route, data, swap order', async () => {
131
+ const harness = createHarness(createPage('home'));
132
+ const next = createPage('list');
133
+
134
+ const navigation = harness.sequencer.navigate(createRequest(next), { extra: true }, 'nav:1');
135
+ await flush();
136
+ assert.deepEqual(harness.log, ['loading:true', 'resolve:list', 'fetch:list:fresh']);
137
+ assert.equal(harness.committed().chunkId, 'home');
138
+
139
+ next.fetches[0].resolve({ rows: [1] });
140
+ await navigation;
141
+
142
+ assert.deepEqual(harness.log, ['loading:true', 'resolve:list', 'fetch:list:fresh', 'startRender', 'commit:list']);
143
+ assert.deepEqual(next.data, { rows: [1], extra: true });
144
+ assert.equal(harness.sequencer.current(), next);
145
+ // The swap render releases the loader and fires page.ready, not the sequencer
146
+ assert.equal(next.navigation.status, 'ready');
147
+ });
148
+
149
+ test('a page declaring deferred navigation stays blocking when it does not defer', async () => {
150
+ const harness = createHarness(createPage('home'));
151
+ const next = createPage('list', { defers: false });
152
+
153
+ const navigation = harness.sequencer.navigate(createRequest(next));
154
+ await flush();
155
+ next.fetches[0].resolve({});
156
+ await navigation;
157
+
158
+ assert.equal(harness.log.includes('prepare:list'), false);
159
+ assert.deepEqual(harness.log.slice(-2), ['startRender', 'commit:list']);
160
+ });
161
+
162
+ test('show() swaps a resolved error page without the loader', async () => {
163
+ const harness = createHarness(createPage('home'));
164
+ const errorPage = createPage('error-404');
165
+
166
+ const navigation = harness.sequencer.show(errorPage, { message: 'Not found' });
167
+ await flush();
168
+ errorPage.fetches[0].resolve({});
169
+ await navigation;
170
+
171
+ assert.deepEqual(harness.log, ['fetch:error-404:fresh', 'startRender', 'commit:error-404']);
172
+ assert.equal(errorPage.data.message, 'Not found');
173
+ });
174
+
175
+ test('a blocking data failure is logged, releases the loader and keeps the current page', async () => {
176
+ const harness = createHarness(createPage('home'));
177
+ const next = createPage('list');
178
+
179
+ const navigation = harness.sequencer.navigate(createRequest(next));
180
+ await flush();
181
+ next.fetches[0].reject(new Error('offline'));
182
+ await navigation;
183
+
184
+ assert.deepEqual(harness.log.slice(-2), ['fail:data:offline', 'loading:false']);
185
+ assert.equal(harness.committed().chunkId, 'home');
186
+ });
187
+
188
+ /*----------------------------------
189
+ - DEFERRED
190
+ ----------------------------------*/
191
+
192
+ test('deferred navigation swaps the page in before its data', async () => {
193
+ const harness = createHarness(createPage('home'));
194
+ const next = createPage('list', { defers: true });
195
+
196
+ const navigation = harness.sequencer.navigate(createRequest(next), { extra: true });
197
+ await flush();
198
+
199
+ assert.deepEqual(harness.log, [
200
+ 'loading:true',
201
+ 'resolve:list',
202
+ 'prepare:list',
203
+ 'startRender',
204
+ 'commit:list',
205
+ 'fetch:list:prepared',
206
+ ]);
207
+ assert.equal(next.navigation.status, 'pending');
208
+ assert.equal(next.navigation.pending, true);
209
+ assert.equal(next.navigation.stale, false);
210
+ assert.equal(next.navigation.error, null);
211
+ assert.equal(typeof next.navigation.since, 'number');
212
+
213
+ next.fetches[0].resolve({ rows: [1] });
214
+ await navigation;
215
+
216
+ assert.deepEqual(harness.log.slice(-3), ['render:list:ready:replace', 'loading:false', 'ready:list']);
217
+ assert.deepEqual(next.data, { rows: [1], extra: true });
218
+ assert.equal(next.navigation.status, 'ready');
219
+ });
220
+
221
+ test('a throwing data provider aborts a deferred navigation before the swap', async () => {
222
+ const harness = createHarness(createPage('home'));
223
+ const next = createPage('list', { defers: true, prepareError: new Error('bad params') });
224
+
225
+ await harness.sequencer.navigate(createRequest(next));
226
+
227
+ assert.deepEqual(harness.log, ['loading:true', 'resolve:list', 'prepare:list', 'fail:data:bad params', 'loading:false']);
228
+ assert.equal(harness.committed().chunkId, 'home');
229
+ });
230
+
231
+ test('a same-chunk deferred navigation keeps the previous data on screen', async () => {
232
+ const harness = createHarness(createPage('list'));
233
+ const sameChunk = createPage('list', { defers: true });
234
+
235
+ harness.sequencer.navigate(createRequest(sameChunk));
236
+ await flush();
237
+ assert.equal(sameChunk.navigation.stale, true);
238
+
239
+ // Cross-chunk: the Page component remounts, nothing to keep
240
+ const otherChunk = createPage('detail', { defers: true });
241
+ harness.sequencer.navigate(createRequest(otherChunk));
242
+ await flush();
243
+ assert.equal(otherChunk.navigation.stale, false);
244
+
245
+ // Same chunk as a page that never showed data: nothing to keep either
246
+ const afterPending = createPage('detail', { defers: true });
247
+ harness.sequencer.navigate(createRequest(afterPending));
248
+ await flush();
249
+ assert.equal(afterPending.navigation.stale, false);
250
+ });
251
+
252
+ test('a deferred page whose provider redirects never swaps in', async () => {
253
+ const harness = createHarness(createPage('home'));
254
+ const login = createPage('login');
255
+ const redirecting = createPage('account', { defers: true });
256
+ let redirect;
257
+ // Router.go('/login') from the provider starts the next navigation synchronously
258
+ redirecting.onPrepare = () => (redirect = harness.sequencer.navigate(createRequest(login)));
259
+
260
+ harness.sequencer.navigate(createRequest(redirecting));
261
+ await flush();
262
+ login.fetches[0].resolve({});
263
+ await redirect;
264
+
265
+ assert.equal(harness.log.includes('commit:account'), false);
266
+ assert.equal(harness.log.includes('fetch:account:prepared'), false);
267
+ assert.equal(harness.committed(), login);
268
+ });
269
+
270
+ /*----------------------------------
271
+ - RACES
272
+ ----------------------------------*/
273
+
274
+ test('a slower chunk of an older navigation never commits', async () => {
275
+ const harness = createHarness(createPage('home'));
276
+ const slowChunk = createDeferred();
277
+ const slow = createPage('slow', { defers: true });
278
+ const fast = createPage('fast', { defers: true });
279
+
280
+ const slowNavigation = harness.sequencer.navigate(createRequest(slow, slowChunk.promise));
281
+ const fastNavigation = harness.sequencer.navigate(createRequest(fast));
282
+ await flush();
283
+ fast.fetches[0].resolve({});
284
+ await fastNavigation;
285
+
286
+ slowChunk.resolve();
287
+ await slowNavigation;
288
+
289
+ assert.equal(harness.committed(), fast);
290
+ assert.equal(harness.log.includes('commit:slow'), false);
291
+ assert.equal(harness.log.includes('prepare:slow'), false);
292
+ assert.equal(harness.log.filter((entry) => entry === 'loading:false').length, 1);
293
+ });
294
+
295
+ test('older data never lands on a newer page', async () => {
296
+ const harness = createHarness(createPage('home'));
297
+ const blockingFirst = createPage('blocking');
298
+ const deferredFirst = createPage('first', { defers: true });
299
+ const latest = createPage('latest', { defers: true });
300
+
301
+ // Blocking: the older data arrives after a newer navigation started
302
+ const blockingNavigation = harness.sequencer.navigate(createRequest(blockingFirst));
303
+ await flush();
304
+ const deferredNavigation = harness.sequencer.navigate(createRequest(deferredFirst));
305
+ await flush();
306
+ blockingFirst.fetches[0].resolve({});
307
+ await blockingNavigation;
308
+ assert.equal(harness.log.includes('commit:blocking'), false);
309
+
310
+ // Deferred: the first page is on screen, then replaced before its data
311
+ const latestNavigation = harness.sequencer.navigate(createRequest(latest));
312
+ await flush();
313
+ deferredFirst.fetches[0].resolve({ rows: [1] });
314
+ await deferredNavigation;
315
+ assert.equal(harness.log.includes('render:first:ready:replace'), false);
316
+ assert.equal(harness.log.includes('ready:first'), false);
317
+
318
+ latest.fetches[0].resolve({});
319
+ await latestNavigation;
320
+ assert.equal(harness.sequencer.current(), latest);
321
+ assert.equal(harness.log.at(-1), 'ready:latest');
322
+ });
323
+
324
+ test('data that lands while a newer navigation resolves renders but leaves the loader on', async () => {
325
+ const harness = createHarness(createPage('home'));
326
+ const shown = createPage('shown', { defers: true });
327
+ const nextChunk = createDeferred();
328
+ const next = createPage('next', { defers: true });
329
+
330
+ const shownNavigation = harness.sequencer.navigate(createRequest(shown));
331
+ await flush();
332
+ const nextNavigation = harness.sequencer.navigate(createRequest(next, nextChunk.promise));
333
+ await flush();
334
+
335
+ shown.fetches[0].resolve({});
336
+ await shownNavigation;
337
+ assert.deepEqual(harness.log.slice(-2), ['render:shown:ready:replace', 'ready:shown']);
338
+
339
+ nextChunk.resolve();
340
+ await flush();
341
+ next.fetches[0].resolve({});
342
+ await nextNavigation;
343
+ assert.deepEqual(harness.log.slice(-3), ['render:next:ready:replace', 'loading:false', 'ready:next']);
344
+ });
345
+
346
+ test('a rejected chunk is logged and releases the loader', async () => {
347
+ const harness = createHarness(createPage('home'));
348
+ const broken = createPage('broken', { defers: true });
349
+
350
+ await harness.sequencer.navigate(
351
+ createRequest(broken, Promise.reject(new Error('A new version of the website is available.'))),
352
+ );
353
+
354
+ assert.deepEqual(harness.log, [
355
+ 'loading:true',
356
+ 'resolve:broken',
357
+ 'fail:route:A new version of the website is available.',
358
+ 'loading:false',
359
+ ]);
360
+ assert.equal(harness.committed().chunkId, 'home');
361
+
362
+ // The next navigation still works
363
+ const next = createPage('next');
364
+ const navigation = harness.sequencer.navigate(createRequest(next));
365
+ await flush();
366
+ next.fetches[0].resolve({});
367
+ await navigation;
368
+ assert.equal(harness.committed(), next);
369
+ });
370
+
371
+ test('an error page loaded for a superseded navigation never builds its response', async () => {
372
+ const router = routerHarness.createRouter();
373
+ let loads = 0;
374
+ router.errors[500] = {
375
+ index: 0,
376
+ code: 500,
377
+ chunk: 'error500',
378
+ load: async () => {
379
+ loads++;
380
+ return { __register: () => ({ code: 500, options: {}, controller: () => assert.fail('controller ran') }) };
381
+ },
382
+ };
383
+ const originalLog = console.log;
384
+ console.log = () => {};
385
+
386
+ try {
387
+ // A newer navigation started while the error chunk loaded
388
+ const page = await router['createErrorResponse'](new Error('boom'), {}, {}, () => false);
389
+ assert.equal(page, null);
390
+ } finally {
391
+ console.log = originalLog;
392
+ }
393
+
394
+ assert.equal(loads, 1);
395
+ // The loaded chunk is kept for the next error
396
+ assert.equal('load' in router.errors[500], false);
397
+ });
398
+
399
+ /*----------------------------------
400
+ - ERROR AND RETRY
401
+ ----------------------------------*/
402
+
403
+ test('a failed deferred data step shows an error that retry() recovers', async () => {
404
+ const harness = createHarness(createPage('home'));
405
+ const page = createPage('list', { defers: true });
406
+
407
+ const navigation = harness.sequencer.navigate(createRequest(page));
408
+ await flush();
409
+ page.fetches[0].reject(new Error('offline'));
410
+ await navigation;
411
+
412
+ assert.equal(page.navigation.status, 'error');
413
+ assert.equal(page.navigation.error.message, 'offline');
414
+ assert.equal(page.navigation.stale, false);
415
+ assert.deepEqual(harness.log.slice(-3), ['render:list:error', 'fail:data:offline', 'loading:false']);
416
+ assert.equal(harness.log.includes('ready:list'), false);
417
+
418
+ // Retry re-runs only the data step: no route, no swap, no loader. The data never reached the screen, so it
419
+ // is a first load: it replaces the page state rather than merging into what the Page component held
420
+ harness.log.length = 0;
421
+ page.navigation.retry();
422
+ assert.equal(page.navigation.status, 'pending');
423
+ assert.equal(page.navigation.stale, false);
424
+ assert.equal(page.navigation.reloading, false);
425
+ page.fetches[1].resolve({ rows: [1] });
426
+ await flush();
427
+
428
+ assert.deepEqual(harness.log, [
429
+ 'render:list:pending',
430
+ 'fetch:list:fresh',
431
+ 'render:list:ready:replace',
432
+ 'loading:false',
433
+ 'ready:list',
434
+ ]);
435
+ assert.equal(page.navigation.reloading, false);
436
+
437
+ // A later retry (api.reload) keeps this page's own data on screen, merges, and does not fire ready again
438
+ harness.log.length = 0;
439
+ page.navigation.retry();
440
+ assert.equal(page.navigation.stale, true);
441
+ assert.equal(page.navigation.reloading, true);
442
+ page.fetches[2].resolve({ rows: [2] });
443
+ await flush();
444
+
445
+ assert.deepEqual(harness.log, ['render:list:pending', 'fetch:list:fresh', 'render:list:ready:merge', 'loading:false']);
446
+ assert.deepEqual(page.data, { rows: [2] });
447
+
448
+ // A reload that fails keeps saying so: the data on screen is still the page's own
449
+ page.navigation.retry();
450
+ page.fetches[3].reject(new Error('offline'));
451
+ await flush();
452
+ assert.equal(page.navigation.status, 'error');
453
+ assert.equal(page.navigation.reloading, true);
454
+ });
455
+
456
+ test('a same-chunk navigation is stale but never reloading: the data on screen is the previous page\'s', async () => {
457
+ const harness = createHarness(createPage('list'));
458
+ const next = createPage('list', { defers: true });
459
+
460
+ harness.sequencer.navigate(createRequest(next));
461
+ await flush();
462
+
463
+ assert.equal(next.navigation.stale, true);
464
+ assert.equal(next.navigation.reloading, false);
465
+
466
+ // A retry of its failed first data step is still not a reload
467
+ next.fetches[0].reject(new Error('offline'));
468
+ await flush();
469
+ next.navigation.retry();
470
+ assert.equal(next.navigation.stale, true);
471
+ assert.equal(next.navigation.reloading, false);
472
+ });
473
+
474
+ test('retry() of a page that left the screen does nothing, and the SSR page can retry', async () => {
475
+ const initial = createPage('home', { defers: true });
476
+ const harness = createHarness(initial);
477
+ const staleRetry = initial.navigation.retry;
478
+
479
+ // The SSR page is bound at creation
480
+ harness.sequencer.retry();
481
+ assert.deepEqual(harness.log, ['render:home:pending', 'fetch:home:fresh']);
482
+ initial.fetches[0].resolve({});
483
+ await flush();
484
+
485
+ const next = createPage('next');
486
+ const navigation = harness.sequencer.navigate(createRequest(next));
487
+ await flush();
488
+ next.fetches[0].resolve({});
489
+ await navigation;
490
+
491
+ harness.log.length = 0;
492
+ staleRetry();
493
+ // A blocking page has no data step to retry
494
+ await harness.sequencer.retry();
495
+ assert.deepEqual(harness.log, []);
496
+ });
497
+
498
+ /*----------------------------------
499
+ - CLIENT PAGE
500
+ ----------------------------------*/
501
+
502
+ test('a deferred data step reuses the fetchers its providers built before the swap', async () => {
503
+ let providerRuns = 0;
504
+ const route = {
505
+ path: '/radars',
506
+ options: { id: 'radars', navigation: 'deferred' },
507
+ data: () => {
508
+ providerRuns++;
509
+ return { radars: Promise.resolve(['one']), view: 'grid' };
510
+ },
511
+ };
512
+ const mode = { mode: 'deferred' };
513
+ const context = {
514
+ route,
515
+ request: {
516
+ url: 'http://localhost/radars',
517
+ data: {},
518
+ api: {
519
+ fetchSync: async (fetchers, loaded) => {
520
+ const data = { ...loaded };
521
+ for (const key in fetchers) data[key] = await fetchers[key];
522
+ return data;
523
+ },
524
+ },
525
+ },
526
+ Router: { config: { navigation: mode } },
527
+ };
528
+ const page = new ClientPage(route, () => null, context);
529
+
530
+ assert.equal(page.isDeferred(), true);
531
+ mode.mode = 'blocking';
532
+ assert.equal(page.isDeferred(), false);
533
+
534
+ page.prepareFetchers();
535
+ await page.preRender(undefined, page.fetchers);
536
+ assert.equal(providerRuns, 1);
537
+ assert.deepEqual(page.data, { radars: ['one'], view: 'grid' });
538
+ assert.equal(context.page, page);
539
+
540
+ // Without prepared fetchers (blocking navigation, retry), the providers run again
541
+ page.data = {};
542
+ await page.preRender();
543
+ assert.equal(providerRuns, 2);
544
+ });
545
+
546
+ test('a deferred page rejects a "navigation" key from its layout data too', () => {
547
+ const route = { path: '/radars', options: { id: 'radars', navigation: 'deferred' }, data: () => ({ rows: [] }) };
548
+ const context = { route, request: { url: 'http://localhost/radars', data: {}, api: {} }, Router: { config: {} } };
549
+ const layout = { path: '/', Component: () => null, data: () => ({ navigation: { menu: [] } }) };
550
+ const page = new ClientPage(route, () => null, context, layout);
551
+
552
+ assert.throws(() => page.prepareFetchers(), /Layout data .* cannot return key "navigation"/);
553
+
554
+ // The same layout is fine under a blocking page
555
+ const blockingRoute = { ...route, options: { id: 'radars' } };
556
+ const blockingPage = new ClientPage(blockingRoute, () => null, { ...context, route: blockingRoute }, layout);
557
+ assert.deepEqual(Object.keys(blockingPage.prepareFetchers()), ['rows', 'navigation']);
558
+ });
559
+
560
+ /*----------------------------------
561
+ - ROUTER PORTS
562
+ ----------------------------------*/
563
+
564
+ const createRouterPorts = ({ initialPage, resolve }) => {
565
+ const handled = [];
566
+ const loading = [];
567
+ const clientRouter = {
568
+ app: { handleError: (error) => handled.push(error) },
569
+ setLoading: (value) => loading.push(value),
570
+ resolve,
571
+ };
572
+ const view = { setCurrentPage: () => {}, setReadyCount: () => {} };
573
+ const context = { page: initialPage };
574
+ const navigation = createRouterNavigation(clientRouter, context, view, initialPage);
575
+
576
+ return { navigation, handled, loading, context };
577
+ };
578
+
579
+ // A deferred page as the router ports see it, holding the Page component's data state
580
+ const createRenderedPage = (fetched) => {
581
+ const page = {
582
+ chunkId: 'radars',
583
+ data: {},
584
+ fetchers: {},
585
+ navigation: createPageNavigation({ status: 'ready' }),
586
+ state: { rows: ['previous'], note: 'written by api.set' },
587
+ isDeferred: () => true,
588
+ prepareFetchers: () => {},
589
+ preRender: async () => {
590
+ page.data = fetched();
591
+ },
592
+ setAllData: (update) => {
593
+ page.state = update(page.state);
594
+ },
595
+ setNavigation: () => {},
596
+ };
597
+ return page;
598
+ };
599
+
600
+ test('a route chunk that fails to load reaches the app error handler and releases the loader', async () => {
601
+ const { navigation, handled, loading } = createRouterPorts({
602
+ resolve: async () => {
603
+ throw new Error('A new version of the website is available. Please refresh the page.');
604
+ },
605
+ });
606
+ const originalError = console.error;
607
+ console.error = () => {};
608
+
609
+ try {
610
+ await navigation.sequencer.navigate({ path: '/radars' });
611
+ } finally {
612
+ console.error = originalError;
613
+ }
614
+
615
+ assert.equal(handled.length, 1);
616
+ assert.match(handled[0].message, /new version/);
617
+ assert.deepEqual(loading, [true, false]);
618
+ });
619
+
620
+ test('a reload merges into the data written with api.set, a navigation replaces it', async () => {
621
+ let rows = ['reloaded'];
622
+ const shown = createRenderedPage(() => ({ rows }));
623
+ const { navigation } = createRouterPorts({ initialPage: shown, resolve: async () => next });
624
+
625
+ await navigation.sequencer.retry();
626
+ assert.deepEqual(shown.state, { rows: ['reloaded'], note: 'written by api.set' });
627
+
628
+ // The first data render of a navigation replaces what the Page component held
629
+ const next = createRenderedPage(() => ({ rows: ['next'] }));
630
+ next.state = { leftover: true };
631
+ await navigation.sequencer.navigate({ path: '/radars?page=2' });
632
+ assert.deepEqual(next.state, { rows: ['next'] });
633
+ });
634
+
635
+ test('a retry that recovers a failed first data step replaces the previous page\'s data', async () => {
636
+ let fail = true;
637
+ const shown = createRenderedPage(() => ({ rows: ['previous'] }));
638
+ const next = createRenderedPage(() => {
639
+ if (fail) throw new Error('offline');
640
+ return { rows: ['next'] };
641
+ });
642
+ const { navigation } = createRouterPorts({ initialPage: shown, resolve: async () => next });
643
+ const originalError = console.error;
644
+ console.error = () => {};
645
+
646
+ try {
647
+ // Same chunk: the Page component, and its state, stay those of the previous page
648
+ next.state = { rows: ['previous'], note: 'previous page only' };
649
+ await navigation.sequencer.navigate({ path: '/radars?page=2' });
650
+ assert.equal(next.navigation.status, 'error');
651
+
652
+ fail = false;
653
+ await navigation.sequencer.retry();
654
+ } finally {
655
+ console.error = originalError;
656
+ }
657
+
658
+ assert.deepEqual(next.state, { rows: ['next'] });
659
+ });
660
+
661
+ test('the context keeps the page on screen until a navigation commits', async () => {
662
+ const shown = createRenderedPage(() => ({ rows: [] }));
663
+ const superseded = createRenderedPage(() => ({ rows: [] }));
664
+ // Blocking, with its data held: resolved and current, but not swapped in yet
665
+ const latest = createRenderedPage(() => ({ rows: [] }));
666
+ const latestData = createDeferred();
667
+ latest.isDeferred = () => false;
668
+ latest.preRender = () => latestData.promise;
669
+ const routes = { '/radars/1': createDeferred(), '/radars/2': createDeferred() };
670
+ const { navigation, context } = createRouterPorts({
671
+ initialPage: shown,
672
+ resolve: (request, isCurrent) => routes[request.path].promise.then((page) => (isCurrent() ? page : null)),
673
+ });
674
+
675
+ const supersededNavigation = navigation.sequencer.navigate({ path: '/radars/1' });
676
+ const latestNavigation = navigation.sequencer.navigate({ path: '/radars/2' });
677
+
678
+ // A navigation superseded during its route load never touches the context
679
+ routes['/radars/1'].resolve(superseded);
680
+ await supersededNavigation;
681
+ assert.equal(context.page, shown);
682
+
683
+ // Resolved and current, its data still loading: the page on screen still answers api.set and api.reload
684
+ routes['/radars/2'].resolve(latest);
685
+ await flush();
686
+ assert.equal(context.page, shown);
687
+
688
+ latestData.resolve();
689
+ await latestNavigation;
690
+ assert.equal(context.page, latest);
691
+ });
692
+
693
+ test('a response built for a navigation leaves the router context on the page on screen', () => {
694
+ const ClientPageResponse = require('../client/services/router/response/index.tsx').default;
695
+ const pageOnScreen = { chunkId: 'radars' };
696
+ const router = { context: { page: pageOnScreen }, config: { context: () => ({}) } };
697
+ const request = { app: {}, api: {}, router };
698
+
699
+ const response = new ClientPageResponse(request, { options: {} });
700
+
701
+ // The response's own context starts without a page; the router's keeps the one on screen
702
+ assert.equal(router.context.page, pageOnScreen);
703
+ assert.equal(router.context.request, request);
704
+ assert.equal(response.context.page, undefined);
705
+ });
706
+
707
+ /*----------------------------------
708
+ - ROUTER COMPONENT
709
+ ----------------------------------*/
710
+
711
+ const { options: preactOptions, render: renderInto } = require('preact');
712
+ const { createFakeDocument } = require('./fakeDom.cjs');
713
+ const { default: RouterComponent, scrollToHash } = require('../client/services/router/components/router.tsx');
714
+
715
+ // A page as the router component and the Page component read it, whose data the test releases
716
+ const createMountedPage = (chunkId, { deferred, log }) => {
717
+ const data = createDeferred();
718
+ const page = {
719
+ chunkId,
720
+ data: {},
721
+ fetchers: {},
722
+ navigation: createPageNavigation({ status: 'ready' }),
723
+ context: { request: { path: `/${chunkId}` } },
724
+ route: { path: `/${chunkId}`, options: deferred ? { navigation: 'deferred' } : {} },
725
+ renderer: (props) => {
726
+ log.push(`render:${chunkId}:${props.rows ? 'data' : 'empty'}`);
727
+ return null;
728
+ },
729
+ isDeferred: () => deferred,
730
+ prepareFetchers: () => ({}),
731
+ preRender: async () => {
732
+ page.data = await data.promise;
733
+ return page;
734
+ },
735
+ updateClient: () => log.push(`updateClient:${chunkId}`),
736
+ setAllData: () => {},
737
+ setNavigation: () => {},
738
+ releaseData: data.resolve,
739
+ };
740
+ return page;
741
+ };
742
+
743
+ test('page.ready waits for the data render when the data lands before the swap\'s effects run', async () => {
744
+ const log = [];
745
+ const frames = [];
746
+ const previousFrame = preactOptions.requestAnimationFrame;
747
+ const previousDocument = { ...global.document };
748
+ // Effects run when the test paints a frame, so the data can land between a render and its effects
749
+ preactOptions.requestAnimationFrame = (callback) => frames.push(callback);
750
+ const paint = () => frames.splice(0).forEach((callback) => callback());
751
+ Object.assign(global.document, createFakeDocument());
752
+ const root = global.document.createElement('div');
753
+
754
+ try {
755
+ const home = createMountedPage('home', { deferred: false, log });
756
+ const radars = createMountedPage('radars', { deferred: true, log });
757
+ const clientRouter = { app: { handleError: () => {} }, runHook: (name) => log.push(`hook:${name}`) };
758
+ // One context for the router component (harness stub) and the Page component (this file's stub)
759
+ pageContext = { page: home, request: home.context.request, data: {} };
760
+ routerHarness.setClientContext(pageContext);
761
+
762
+ renderInto(h(RouterComponent, { service: clientRouter }), root);
763
+ paint();
764
+ log.length = 0;
765
+
766
+ const navigation = clientRouter.navigate(radars);
767
+ // The swap renders the pending page; its effects wait for the next frame
768
+ await flush();
769
+ // The data lands within that frame
770
+ radars.releaseData({ rows: [1] });
771
+ await navigation;
772
+ // The next render runs the swap's pending effects first, then the data render
773
+ await flush();
774
+ paint();
775
+
776
+ const dataRender = log.indexOf('render:radars:data');
777
+ const ready = log.indexOf('hook:page.ready');
778
+ assert.ok(dataRender >= 0, log.join(' '));
779
+ assert.ok(ready > dataRender, `page.ready before the data render: ${log.join(' ')}`);
780
+ assert.equal(log.filter((entry) => entry === 'hook:page.ready').length, 1);
781
+ // The title and body refresh after the data render ran
782
+ assert.ok(log.lastIndexOf('updateClient:radars') > dataRender, log.join(' '));
783
+ } finally {
784
+ renderInto(null, root);
785
+ routerHarness.setClientContext({});
786
+ preactOptions.requestAnimationFrame = previousFrame;
787
+ for (const key of Object.keys(global.document)) if (!(key in previousDocument)) delete global.document[key];
788
+ }
789
+ });
790
+
791
+ test('a hash scrolls to the element with that id, decoded, never to a tag selector', () => {
792
+ const lookedUp = [];
793
+ const scrolled = [];
794
+ global.document.getElementById = (id) => {
795
+ lookedUp.push(id);
796
+ return id === 'prices' || id === 'café' ? { scrollIntoView: () => scrolled.push(id) } : null;
797
+ };
798
+
799
+ try {
800
+ scrollToHash('#prices');
801
+ scrollToHash('#caf%C3%A9');
802
+ // An id a selector would refuse, and a malformed escape, are looked up as ids all the same
803
+ scrollToHash('#2026-prices');
804
+ scrollToHash('#100%');
805
+ scrollToHash('#');
806
+ } finally {
807
+ delete global.document.getElementById;
808
+ }
809
+
810
+ assert.deepEqual(lookedUp, ['prices', 'café', '2026-prices', '100%']);
811
+ assert.deepEqual(scrolled, ['prices', 'café']);
812
+ });
813
+
814
+ /*----------------------------------
815
+ - PAGE COMPONENT
816
+ ----------------------------------*/
817
+
818
+ const renderPage = ({ declared, navigation, data }) => {
819
+ let received;
820
+ const page = {
821
+ data,
822
+ navigation,
823
+ route: { options: declared ? { navigation: declared } : {} },
824
+ renderer: (props) => {
825
+ received = props;
826
+ return null;
827
+ },
828
+ };
829
+ pageContext = { data: {} };
830
+ renderToString(h(PageComponent, { page }));
831
+
832
+ return { page, received };
833
+ };
834
+
835
+ test('the Page component gives the navigation prop to deferred pages only, and binds both setters', () => {
836
+ const pending = createPageNavigation({ status: 'pending', stale: false });
837
+ const deferredPage = renderPage({ declared: 'deferred', navigation: pending, data: {} });
838
+
839
+ assert.equal(deferredPage.received.navigation, pending);
840
+ assert.equal(deferredPage.received.rows, undefined);
841
+ assert.equal(typeof deferredPage.page.setNavigation, 'function');
842
+ assert.equal(typeof deferredPage.page.setAllData, 'function');
843
+
844
+ // A blocking page may return its own `navigation` data key
845
+ const blockingPage = renderPage({
846
+ declared: undefined,
847
+ navigation: createPageNavigation({ status: 'ready' }),
848
+ data: { navigation: 'site menu', rows: [1] },
849
+ });
850
+ assert.equal(blockingPage.received.navigation, 'site menu');
851
+ assert.deepEqual(blockingPage.received.rows, [1]);
852
+ });
853
+
854
+ afterAll(() => {
855
+ routerHarness.restore();
856
+ Module._load = originalLoad;
857
+ });