Skip to content

Commit a2f366f

Browse files
AvcharovNothingEverHappens
authored andcommitted
feat(devtools): add transfer state tab (#62465)
Add transfer state tab, which is taking transfer state script by using APP_ID. Created internal api ɵgetTransferState to retrieve transfer state value from app into devtools app. PR Close #62465
1 parent 4138aca commit a2f366f

File tree

17 files changed

+895
-2
lines changed

17 files changed

+895
-2
lines changed

‎devtools/projects/ng-devtools-backend/src/lib/client-event-subscribers.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
SerializedInjector,
2222
SerializedProviderRecord,
2323
SignalNodePosition,
24+
TransferStateValue,
2425
} from '../../../protocol';
2526
import {debounceTime} from 'rxjs/operators';
2627
import {
@@ -105,6 +106,8 @@ export const subscribeToClientEvents = (
105106

106107
messageBus.on('logProvider', logProvider);
107108

109+
messageBus.on('getTransferState', getTransferStateCallback(messageBus));
110+
108111
messageBus.on('log', ({message, level}) => {
109112
console[level](`[Angular DevTools]: ${message}`);
110113
});
@@ -662,6 +665,43 @@ const logProvider = (
662665
console.groupEnd();
663666
};
664667

668+
const getTransferStateCallback = (messageBus: MessageBus<Events>) => () => {
669+
const ng = ngDebugClient();
670+
671+
const forest = initializeOrGetDirectiveForestHooks().getIndexedDirectiveForest();
672+
if (forest.length === 0) {
673+
messageBus.emit('transferStateData', [null]);
674+
return;
675+
}
676+
677+
const rootNode = forest[0];
678+
if (!rootNode || !rootNode.nativeElement) {
679+
messageBus.emit('transferStateData', [null]);
680+
return;
681+
}
682+
683+
const injector = getInjectorFromElementNode(rootNode.nativeElement);
684+
if (!injector) {
685+
messageBus.emit('transferStateData', [null]);
686+
return;
687+
}
688+
689+
const transferStateData = (ng.ɵgetTransferState?.(injector) ?? null) as Record<
690+
string,
691+
TransferStateValue
692+
> | null;
693+
694+
if (
695+
transferStateData &&
696+
typeof transferStateData === 'object' &&
697+
Object.keys(transferStateData).length > 0
698+
) {
699+
messageBus.emit('transferStateData', [transferStateData]);
700+
} else {
701+
messageBus.emit('transferStateData', [null]);
702+
}
703+
};
704+
665705
const getInjectorInstance = (
666706
serializedInjector: SerializedInjector,
667707
serializedProvider: SerializedProviderRecord,

‎devtools/projects/ng-devtools-backend/src/lib/ng-debug-api/ng-debug-api.spec.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ngDebugProfilerApiIsSupported,
1313
ngDebugRoutesApiIsSupported,
1414
ngDebugSignalGraphApiIsSupported,
15+
ngDebugTransferStateApiIsSupported,
1516
} from './ng-debug-api';
1617
import {Framework} from '../component-tree/core-enums';
1718

@@ -134,4 +135,24 @@ describe('ng-debug-api', () => {
134135
expect(ngDebugSignalGraphApiIsSupported()).toBeFalse();
135136
});
136137
});
138+
139+
describe('ngDebugTransferStateApiIsSupported', () => {
140+
beforeEach(() => mockRoot());
141+
142+
it('should support Transfer State API with getTransferState', () => {
143+
(globalThis as any).ng = fakeNgGlobal(Framework.Angular);
144+
(globalThis as any).ng.ɵgetTransferState = () => {};
145+
expect(ngDebugTransferStateApiIsSupported()).toBeTrue();
146+
});
147+
148+
it('should not support Transfer State API with no getTransferState', () => {
149+
(globalThis as any).ng = fakeNgGlobal(Framework.ACX);
150+
(globalThis as any).ng.ɵgetTransferState = 'not implemented';
151+
expect(ngDebugTransferStateApiIsSupported()).toBeFalse();
152+
153+
(globalThis as any).ng = fakeNgGlobal(Framework.ACX);
154+
(globalThis as any).ng.ɵgetTransferState = undefined;
155+
expect(ngDebugTransferStateApiIsSupported()).toBeFalse();
156+
});
157+
});
137158
});

‎devtools/projects/ng-devtools-backend/src/lib/ng-debug-api/ng-debug-api.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,12 @@ export function ngDebugSignalGraphApiIsSupported(): boolean {
8181
const ng = ngDebugClient();
8282
return ngDebugApiIsSupported(ng, 'ɵgetSignalGraph');
8383
}
84+
85+
/**
86+
* Checks if transfer state is available.
87+
* Transfer state is only relevant when Angular app uses Server-Side Rendering.
88+
*/
89+
export function ngDebugTransferStateApiIsSupported(): boolean {
90+
const ng = ngDebugClient();
91+
return ngDebugApiIsSupported(ng, 'ɵgetTransferState');
92+
}

‎devtools/projects/ng-devtools-backend/src/lib/ng-debug-api/supported-apis.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ngDebugProfilerApiIsSupported,
1313
ngDebugRoutesApiIsSupported,
1414
ngDebugSignalGraphApiIsSupported,
15+
ngDebugTransferStateApiIsSupported,
1516
} from './ng-debug-api';
1617

1718
/**
@@ -24,11 +25,13 @@ export function getSupportedApis(): SupportedApis {
2425
const dependencyInjection = ngDebugDependencyInjectionApiIsSupported();
2526
const routes = ngDebugRoutesApiIsSupported();
2627
const signals = ngDebugSignalGraphApiIsSupported();
28+
const transferState = ngDebugTransferStateApiIsSupported();
2729

2830
return {
2931
profiler,
3032
dependencyInjection,
3133
routes,
3234
signals,
35+
transferState,
3336
};
3437
}

‎devtools/projects/ng-devtools/src/lib/devtools-tabs/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ ng_project(
3333
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/profiler:profiler_rjs",
3434
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree:router-tree_rjs",
3535
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/tab-update:tab-update_rjs",
36+
"//devtools/projects/ng-devtools/src/lib/devtools-tabs/transfer-state:transfer-state_rjs",
3637
"//devtools/projects/protocol:protocol_rjs",
3738
],
3839
)

‎devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.html‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@
9292
[providers]="providers()"
9393
/>
9494
}
95+
96+
@let transferStateVisible = activeTab() === 'Transfer State';
97+
@defer (when transferStateVisible; prefetch on idle) {
98+
<ng-transfer-state [hidden]="!transferStateVisible" />
99+
}
95100
</div>
96101
}
97102
</mat-tab-nav-panel>
@@ -137,6 +142,16 @@
137142
<span class="ng-mat-menu-label-text">Enable Signal Graph (experimental)</span>
138143
</label>
139144
}
145+
@if (supportedApis().transferState) {
146+
<label mat-menu-item disableRipple>
147+
<mat-slide-toggle
148+
[checked]="transferStateTabEnabled()"
149+
(change)="setTransferStateTab($event.checked)"
150+
>
151+
Show Transfer State Tab</mat-slide-toggle
152+
>
153+
</label>
154+
}
140155
</div>
141156
</mat-menu>
142157

‎devtools/projects/ng-devtools/src/lib/devtools-tabs/devtools-tabs.component.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ import {DirectiveExplorerComponent} from './directive-explorer/directive-explore
3838
import {InjectorTreeComponent} from './injector-tree/injector-tree.component';
3939
import {ProfilerComponent} from './profiler/profiler.component';
4040
import {RouterTreeComponent} from './router-tree/router-tree.component';
41+
import {TransferStateComponent} from './transfer-state/transfer-state.component';
4142
import {TabUpdate} from './tab-update/index';
4243

43-
type Tab = 'Components' | 'Profiler' | 'Router Tree' | 'Injector Tree';
44+
type Tab = 'Components' | 'Profiler' | 'Router Tree' | 'Injector Tree' | 'Transfer State';
4445

4546
@Component({
4647
selector: 'ng-devtools-tabs',
@@ -59,6 +60,7 @@ type Tab = 'Components' | 'Profiler' | 'Router Tree' | 'Injector Tree';
5960
ProfilerComponent,
6061
RouterTreeComponent,
6162
InjectorTreeComponent,
63+
TransferStateComponent,
6264
MatSlideToggle,
6365
],
6466
providers: [TabUpdate],
@@ -76,6 +78,7 @@ export class DevToolsTabsComponent {
7678
readonly routerGraphEnabled = signal(false);
7779
readonly timingAPIEnabled = signal(false);
7880
readonly signalGraphEnabled = signal(false);
81+
readonly transferStateTabEnabled = signal(false);
7982

8083
readonly componentExplorerView = signal<ComponentExplorerView | null>(null);
8184
readonly providers = signal<SerializedProviderRecord[]>([]);
@@ -97,6 +100,9 @@ export class DevToolsTabsComponent {
97100
if (supportedApis.routes && this.routerGraphEnabled() && this.routes().length > 0) {
98101
tabs.push('Router Tree');
99102
}
103+
if (supportedApis.transferState && this.transferStateTabEnabled()) {
104+
tabs.push('Transfer State');
105+
}
100106

101107
return tabs;
102108
});
@@ -199,4 +205,11 @@ export class DevToolsTabsComponent {
199205
protected setSignalGraph(enabled: boolean): void {
200206
this.signalGraphEnabled.set(enabled);
201207
}
208+
209+
protected setTransferStateTab(enabled: boolean): void {
210+
this.transferStateTabEnabled.set(enabled);
211+
if (!enabled && this.activeTab() === 'Transfer State') {
212+
this.activeTab.set('Components');
213+
}
214+
}
202215
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("//devtools/tools:defaults.bzl", "ng_project", "sass_binary")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
sass_binary(
6+
name = "transfer_state_component_styles",
7+
src = "transfer-state.component.scss",
8+
deps = [
9+
"//devtools/projects/ng-devtools/src/styles:typography",
10+
],
11+
)
12+
13+
ng_project(
14+
name = "transfer-state",
15+
srcs = [
16+
"transfer-state.component.ts",
17+
],
18+
angular_assets = [
19+
":transfer-state.component.html",
20+
":transfer_state_component_styles",
21+
],
22+
deps = [
23+
"//:node_modules/@angular/cdk",
24+
"//:node_modules/@angular/core",
25+
"//:node_modules/@angular/material",
26+
"//devtools/projects/ng-devtools/src/lib/shared/button:button_rjs",
27+
"//devtools/projects/ng-devtools/src/lib/shared/utils:utils_rjs",
28+
"//devtools/projects/protocol:protocol_rjs",
29+
],
30+
)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<div class="transfer-state-container">
2+
<div class="header">
3+
<h2>
4+
<mat-icon>swap_horiz</mat-icon>
5+
Transfer State
6+
</h2>
7+
<div class="actions">
8+
<button ng-button btnType="icon" (click)="refresh()" matTooltip="Refresh transfer state">
9+
<mat-icon>refresh</mat-icon>
10+
</button>
11+
</div>
12+
</div>
13+
14+
@if (isLoading()) {
15+
<div class="loading">
16+
<mat-icon class="spinning">hourglass_empty</mat-icon>
17+
Loading transfer state...
18+
</div>
19+
} @else if (error()) {
20+
<div class="error-card">
21+
<div class="card-header">
22+
<mat-icon>error</mat-icon>
23+
<h3>No Transfer State Found</h3>
24+
</div>
25+
<div class="card-content">
26+
<p>{{ error() }}</p>
27+
<p>
28+
Transfer state is used in Server-Side Rendering (SSR) to pass data from the server to the
29+
client. If you're expecting transfer state data, make sure:
30+
</p>
31+
<ul>
32+
<li>The application is using SSR</li>
33+
<li>Transfer state is being used in your components</li>
34+
<li>You're inspecting the initial page load (not after client-side navigation)</li>
35+
</ul>
36+
</div>
37+
</div>
38+
} @else if (!hasData()) {
39+
<div class="empty-card">
40+
<div class="card-header">
41+
<mat-icon>info</mat-icon>
42+
<h3>Transfer State is Empty</h3>
43+
</div>
44+
<div class="card-content">
45+
<p>No transfer state data found on this page.</p>
46+
<p>This could be normal if the page doesn't use SSR or doesn't transfer any state.</p>
47+
</div>
48+
</div>
49+
} @else {
50+
<div class="transfer-state-content">
51+
<div class="summary">
52+
<div class="summary-card">
53+
<div class="summary-stats">
54+
<div class="stat">
55+
<span class="stat-value">{{ transferStateItems().length }}</span>
56+
<span class="stat-label">Keys</span>
57+
</div>
58+
<div class="stat">
59+
<span class="stat-value">{{ totalSize() }}</span>
60+
<span class="stat-label">Total Size</span>
61+
</div>
62+
</div>
63+
</div>
64+
</div>
65+
66+
<div class="table-container">
67+
<table mat-table [dataSource]="transferStateItems()" class="transfer-state-table">
68+
<!-- Key Column -->
69+
<ng-container matColumnDef="key">
70+
<th mat-header-cell *matHeaderCellDef>Key</th>
71+
<td mat-cell *matCellDef="let item" class="key-cell">
72+
<code>{{ item.key }}</code>
73+
</td>
74+
</ng-container>
75+
76+
<!-- Type Column -->
77+
<ng-container matColumnDef="type">
78+
<th mat-header-cell *matHeaderCellDef>Type</th>
79+
<td mat-cell *matCellDef="let item">
80+
<span class="type-badge type-{{ item.type }}">{{ item.type }}</span>
81+
</td>
82+
</ng-container>
83+
84+
<!-- Size Column -->
85+
<ng-container matColumnDef="size">
86+
<th mat-header-cell *matHeaderCellDef>
87+
Size
88+
<mat-icon class="info-icon" matTooltip="Uncompressed size">help_outline</mat-icon>
89+
</th>
90+
<td mat-cell *matCellDef="let item">{{ item.size }}</td>
91+
</ng-container>
92+
93+
<!-- Value Column -->
94+
<ng-container matColumnDef="value">
95+
<th mat-header-cell *matHeaderCellDef>Value</th>
96+
<td mat-cell *matCellDef="let item" class="value-cell">
97+
<div class="value-container">
98+
<pre
99+
#valuePreview
100+
class="value-preview"
101+
[class.is-expanded]="item.isExpanded"
102+
><code>{{ getFormattedValue(item.value) }}</code></pre>
103+
<div class="action-buttons">
104+
@if (isValueLong(valuePreview, item.isExpanded)) {
105+
<button
106+
ng-button
107+
btnType="icon"
108+
size="compact"
109+
class="expand-button"
110+
(click)="toggleExpanded(item)"
111+
[matTooltip]="item.isExpanded ? 'Collapse value' : 'Expand value'"
112+
>
113+
<mat-icon>{{ item.isExpanded ? 'expand_less' : 'expand_more' }}</mat-icon>
114+
</button>
115+
}
116+
<button
117+
ng-button
118+
btnType="icon"
119+
size="compact"
120+
class="copy-button"
121+
(click)="copyToClipboard(item)"
122+
[matTooltip]="item.isCopied ? 'Copied!' : 'Copy value to clipboard'"
123+
>
124+
<mat-icon>{{ item.isCopied ? 'check' : 'content_copy' }}</mat-icon>
125+
</button>
126+
</div>
127+
</div>
128+
</td>
129+
</ng-container>
130+
131+
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
132+
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
133+
</table>
134+
</div>
135+
</div>
136+
}
137+
</div>

0 commit comments

Comments
 (0)