Skip to content

Commit d2dbcd6

Browse files
authored
Merge pull request #9981 from ever-co/develop
Release: router-owned URL param writes + the faster build gate
2 parents 054bca8 + a6bdfdb commit d2dbcd6

6 files changed

Lines changed: 326 additions & 81 deletions

File tree

.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"investigable",
3232
"Njcmlwd",
3333
"parameterizing",
34+
"macrotask",
3435
"predefines",
3536
"rects",
3637
"Slideless",

.github/workflows/build.yml

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,22 @@ name: Build
55
# which exceeds the 60-minute job cap — so the cache was never written and every run started cold.
66
# Self-hosted runners have no such cap.
77
#
8-
# Job names and the commands they run mirror .circleci/config.yml one-for-one so PR-time build
9-
# coverage is unchanged.
8+
# LATENCY IS A CORRECTNESS PROPERTY OF THIS GATE. The 2026-08-13 template-error incident was not
9+
# a coverage gap — build-web caught the error — but its verdict landed 74 minutes AFTER the PR
10+
# had been merged on a still-pending check (~3h19m push-to-verdict). Two structural causes fixed
11+
# here:
12+
# 1. `build-libs` is its own job: `build:package:all` is where every LIBRARY's strict template
13+
# check runs (each lib's tsconfig sets strictTemplates — the app tsconfig is lax in every
14+
# configuration), so the verdict that catches template errors now reports as its own named
15+
# check ~install+~20min after push instead of at the end of the longest job. Treat a
16+
# pending/cancelled `build-libs`/`build-web` as a red: NEVER merge on yellow — the PR
17+
# concurrency group cancels superseded runs, so "no failure" often just means "never ran".
18+
# 2. The `needs: build-monorepo-root` edges are gone: that job produces nothing the others
19+
# consume (each re-installs from scratch), so the edge only added its full ~90 minutes as
20+
# serial latency in front of every build.
21+
# The dev-config "Build packages" pre-step was removed from build-api/build-web: the app builds
22+
# rebuild their dependency libraries themselves (Nx `dependsOn: ^build`, production config), and
23+
# the strict library verdict lives in `build-libs`.
1024

1125
on:
1226
pull_request:
@@ -51,9 +65,12 @@ jobs:
5165
- name: Run postinstall manually
5266
run: yarn postinstall.manual
5367

54-
build-api:
55-
name: build-api
56-
needs: build-monorepo-root
68+
# The strict library verdict, as its own early-reporting check: this is the task set whose
69+
# per-library `strictTemplates` tsconfigs catch template type errors (the class that broke the
70+
# demo webapp image). It has no `needs` edge and no app build behind it, so it reports as soon
71+
# as install + the 71 library builds finish.
72+
build-libs:
73+
name: build-libs
5774
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
5875
timeout-minutes: 180
5976
steps:
@@ -73,12 +90,29 @@ jobs:
7390
- name: Build packages
7491
run: yarn build:package:all
7592

93+
build-api:
94+
name: build-api
95+
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
96+
timeout-minutes: 180
97+
steps:
98+
- uses: actions/checkout@v4
99+
100+
- uses: actions/setup-node@v4
101+
with:
102+
node-version: 24
103+
cache: yarn
104+
105+
- name: Install dependencies
106+
run: yarn install --network-timeout 1000000 --frozen-lockfile --ignore-scripts
107+
108+
- name: Run postinstall manually
109+
run: yarn postinstall.manual
110+
76111
- name: Build API
77112
run: yarn build:api:prod:ci
78113

79114
build-web:
80115
name: build-web
81-
needs: build-monorepo-root
82116
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
83117
timeout-minutes: 180
84118
steps:
@@ -95,15 +129,11 @@ jobs:
95129
- name: Run postinstall manually
96130
run: yarn postinstall.manual
97131

98-
- name: Build packages
99-
run: yarn build:package:all
100-
101132
- name: Build web
102133
run: yarn build:gauzy:prod:ci
103134

104135
build-desktop:
105136
name: build-desktop
106-
needs: build-monorepo-root
107137
# Matches the CircleCI branch filter: desktop was never built on ordinary PR branches.
108138
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/stage' || github.ref == 'refs/heads/master'
109139
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}

apps/gauzy/src/app/app.component.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { AfterViewInit, Component, OnInit } from '@angular/core';
77
import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
88
import { TranslateService } from '@ngx-translate/core';
99
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
10-
import { filter, map, switchMap, take, tap } from 'rxjs';
10+
import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs';
1111
import { pluck, union } from 'underscore';
1212
import { IDateRangePicker, ILanguage, LanguagesEnum } from '@gauzy/contracts';
1313
import { environment } from '@gauzy/ui-config';
@@ -232,6 +232,21 @@ export class AppComponent implements OnInit, AfterViewInit {
232232
// data re-emitted. switchMap drops the previous route's stream on every
233233
// navigation, so exactly one route's data is ever live.
234234
switchMap((route) => route.data),
235+
// A query-param-only navigation (every picker/selector write now issued
236+
// through NavigationService terminates in NavigationEnd — replaceState
237+
// never did) does NOT re-run resolvers: `route.data` replays the SAME
238+
// object resolved for the last full navigation, stale relative to the
239+
// URL just written. Re-applying it stomped `dates$` back to the
240+
// pre-arrow range, and the picker's org-roundtrip derivation then
241+
// rewrote the OLD range into the URL (~500ms later), overwriting the
242+
// user's choice. Reference equality is exact here: the router only
243+
// next()s `route.data` when a resolver actually re-ran
244+
// (advanceActivatedRoute's shallowEqual guard), so this applies route
245+
// data once per RESOLUTION — the same cadence the replaceState world
246+
// had. Deliberately NOT the JSON-deep distinctUntilChange(): two
247+
// different routes can resolve value-identical data, and suppressing
248+
// that transition would skip the new route's bookmark restore.
249+
distinctUntilChanged(),
235250
/**
236251
* Set Date Range Picker Default Unit and Config
237252
*/
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Subject } from 'rxjs';
2+
import { NavigationEnd, NavigationSkipped, Params, Router } from '@angular/router';
3+
import { NavigationService } from './navigation.service';
4+
5+
/**
6+
* Constructed directly — no `TestBed` (the ui-core barrel pulls the whole app
7+
* graph). The router double records `navigate()` calls and lets each test
8+
* control the two inputs the service's scheduling depends on: the in-flight
9+
* navigation (`getCurrentNavigation`) and the settle events stream.
10+
*/
11+
interface RecordedNavigate {
12+
commands: unknown[];
13+
extras: { queryParams?: Params; queryParamsHandling?: string; replaceUrl?: boolean };
14+
}
15+
16+
function createService(overrides: { currentNavigation?: () => unknown } = {}) {
17+
const events$ = new Subject<unknown>();
18+
const navigations: RecordedNavigate[] = [];
19+
const router = {
20+
events: events$.asObservable(),
21+
getCurrentNavigation: overrides.currentNavigation ?? (() => null),
22+
navigate: jest.fn(async (commands: unknown[], extras: RecordedNavigate['extras']) => {
23+
navigations.push({ commands, extras });
24+
return true;
25+
})
26+
} as unknown as Router;
27+
const activatedRoute = {} as never;
28+
const destroyRef = { onDestroy: () => () => {} } as never;
29+
30+
const service = new NavigationService(router, activatedRoute, destroyRef);
31+
return { service, router, events$, navigations };
32+
}
33+
34+
/** The flush is a macrotask — one real timer tick lets it run. */
35+
const tick = () => new Promise<void>((resolve) => setTimeout(resolve));
36+
37+
describe('NavigationService — router-owned query-param writes', () => {
38+
it('writes through router.navigate with merge + replaceUrl (no history entry, tree stays in sync)', async () => {
39+
const { service, navigations } = createService();
40+
41+
await service.updateQueryParams({ date: '2026-08-01', unit_of_time: 'month' });
42+
43+
expect(navigations).toHaveLength(1);
44+
expect(navigations[0].commands).toEqual([]);
45+
expect(navigations[0].extras.queryParamsHandling).toBe('merge');
46+
expect(navigations[0].extras.replaceUrl).toBe(true);
47+
expect(navigations[0].extras.queryParams).toEqual({ date: '2026-08-01', unit_of_time: 'month' });
48+
});
49+
50+
it("maps '' and empty arrays to null — the router's removal — so 'All Teams' still removes ?teamId", async () => {
51+
const { service, navigations } = createService();
52+
53+
await service.updateQueryParams({ teamId: '', tags: [], archived: false });
54+
55+
expect(navigations[0].extras.queryParams).toEqual({ teamId: null, tags: null, archived: false });
56+
});
57+
58+
it('deduplicates array values (the old buildQueryString contract)', async () => {
59+
const { service, navigations } = createService();
60+
61+
await service.updateQueryParams({ tags: ['a', 'b', 'a'] });
62+
63+
expect(navigations[0].extras.queryParams).toEqual({ tags: ['a', 'b'] });
64+
});
65+
66+
it('coalesces a same-tick burst into ONE navigation, last value per key wins', async () => {
67+
const { service, navigations } = createService();
68+
69+
const first = service.updateQueryParams({ teamId: '', projectId: 'p1' });
70+
const second = service.updateQueryParams({ teamId: 't2' });
71+
await Promise.all([first, second]);
72+
73+
expect(navigations).toHaveLength(1);
74+
expect(navigations[0].extras.queryParams).toEqual({ teamId: 't2', projectId: 'p1' });
75+
});
76+
77+
it('parks the patch while a navigation is in flight and retries once it settles — never mid-flight', async () => {
78+
let inFlight: unknown = { id: 1 };
79+
const { service, events$, navigations } = createService({ currentNavigation: () => inFlight });
80+
81+
const write = service.updateQueryParams({ date: '2026-08-01' });
82+
await tick();
83+
// Still in flight at flush time: nothing written, nothing lost.
84+
expect(navigations).toHaveLength(0);
85+
86+
inFlight = null;
87+
events$.next(new NavigationEnd(1, '/pages/x', '/pages/x'));
88+
await write;
89+
90+
expect(navigations).toHaveLength(1);
91+
expect(navigations[0].extras.queryParams).toEqual({ date: '2026-08-01' });
92+
});
93+
94+
it('a NavigationSkipped settle also releases parked patches (idempotent re-write loop terminator)', async () => {
95+
let inFlight: unknown = { id: 1 };
96+
const { service, events$, navigations } = createService({ currentNavigation: () => inFlight });
97+
98+
const write = service.updateQueryParams({ organizationId: 'org-1' });
99+
await tick();
100+
expect(navigations).toHaveLength(0);
101+
102+
inFlight = null;
103+
events$.next(new NavigationSkipped(1, '/pages/x', 'ignored'));
104+
await write;
105+
106+
expect(navigations).toHaveLength(1);
107+
});
108+
109+
it('a non-merge patch replaces the pending accumulation wholesale', async () => {
110+
const { service, navigations } = createService();
111+
112+
const first = service.updateQueryParams({ keepMe: 'no' });
113+
const second = service.updateQueryParams({ onlyMe: 'yes' }, '');
114+
await Promise.all([first, second]);
115+
116+
expect(navigations).toHaveLength(1);
117+
expect(navigations[0].extras.queryParams).toEqual({ onlyMe: 'yes' });
118+
});
119+
});

0 commit comments

Comments
 (0)