@tanstack/devtools 0.6.23 → 0.7.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.
@@ -1,5 +1,6 @@
1
1
  import { createContext, createEffect } from 'solid-js'
2
2
  import { createStore } from 'solid-js/store'
3
+ import { getDefaultActivePlugins } from '../utils/get-default-active-plugins'
3
4
  import { tryParseJson } from '../utils/sanitize'
4
5
  import {
5
6
  TANSTACK_DEVTOOLS_SETTINGS,
@@ -49,6 +50,12 @@ export interface TanStackDevtoolsPlugin {
49
50
  * If not provided, it will be generated based on the name.
50
51
  */
51
52
  id?: string
53
+ /**
54
+ * Whether the plugin should be open by default when there are no active plugins.
55
+ * If true, this plugin will be added to activePlugins on initial load when activePlugins is empty.
56
+ * @default false
57
+ */
58
+ defaultOpen?: boolean
52
59
  /**
53
60
  * Render the plugin UI by using the provided element. This function will be called
54
61
  * when the plugin tab is clicked and expected to be mounted.
@@ -127,26 +134,39 @@ export function getStateFromLocalStorage(
127
134
  return existingState
128
135
  }
129
136
 
130
- const getExistingStateFromStorage = (
137
+ export const getExistingStateFromStorage = (
131
138
  config?: TanStackDevtoolsConfig,
132
139
  plugins?: Array<TanStackDevtoolsPlugin>,
133
140
  ) => {
134
141
  const existingState = getStateFromLocalStorage(plugins)
135
142
  const settings = getSettings()
136
143
 
144
+ const pluginsWithIds =
145
+ plugins?.map((plugin, i) => {
146
+ const id = generatePluginId(plugin, i)
147
+ return {
148
+ ...plugin,
149
+ id,
150
+ }
151
+ }) || []
152
+
153
+ // If no active plugins exist, add plugins with defaultOpen: true
154
+ // Or if there's only 1 plugin, activate it automatically
155
+ let activePlugins = existingState?.activePlugins || []
156
+
157
+ const shouldFillWithDefaultOpenPlugins =
158
+ activePlugins.length === 0 && pluginsWithIds.length > 0
159
+ if (shouldFillWithDefaultOpenPlugins) {
160
+ activePlugins = getDefaultActivePlugins(pluginsWithIds)
161
+ }
162
+
137
163
  const state: DevtoolsStore = {
138
164
  ...initialState,
139
- plugins:
140
- plugins?.map((plugin, i) => {
141
- const id = generatePluginId(plugin, i)
142
- return {
143
- ...plugin,
144
- id,
145
- }
146
- }) || [],
165
+ plugins: pluginsWithIds,
147
166
  state: {
148
167
  ...initialState.state,
149
168
  ...existingState,
169
+ activePlugins,
150
170
  },
151
171
  settings: {
152
172
  ...initialState.settings,
@@ -1,4 +1,5 @@
1
1
  import { createEffect, createMemo, useContext } from 'solid-js'
2
+ import { MAX_ACTIVE_PLUGINS } from '../utils/constants.js'
2
3
  import { DevtoolsContext } from './devtools-context.jsx'
3
4
  import { useDrawContext } from './draw-context.jsx'
4
5
 
@@ -50,7 +51,7 @@ export const usePlugins = () => {
50
51
  const updatedPlugins = isActive
51
52
  ? prev.state.activePlugins.filter((id) => id !== pluginId)
52
53
  : [...prev.state.activePlugins, pluginId]
53
- if (updatedPlugins.length > 3) return prev
54
+ if (updatedPlugins.length > MAX_ACTIVE_PLUGINS) return prev
54
55
  return {
55
56
  ...prev,
56
57
  state: {
@@ -1168,6 +1168,75 @@ const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => {
1168
1168
  text-transform: uppercase;
1169
1169
  letter-spacing: 0.05em;
1170
1170
  `,
1171
+ pluginMarketplaceFeatureBanner: css`
1172
+ margin-top: 1rem;
1173
+ padding: 1.25rem 1.5rem;
1174
+ background: ${t(
1175
+ 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
1176
+ 'linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%)',
1177
+ )};
1178
+ border-radius: 0.75rem;
1179
+ border: 1px solid ${t(colors.blue[400], colors.blue[800])};
1180
+ box-shadow:
1181
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
1182
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
1183
+ `,
1184
+ pluginMarketplaceFeatureBannerContent: css`
1185
+ display: flex;
1186
+ flex-direction: column;
1187
+ gap: 0.75rem;
1188
+ `,
1189
+ pluginMarketplaceFeatureBannerTitle: css`
1190
+ font-size: 1.125rem;
1191
+ font-weight: 700;
1192
+ color: white;
1193
+ margin: 0;
1194
+ display: flex;
1195
+ align-items: center;
1196
+ gap: 0.5rem;
1197
+ `,
1198
+ pluginMarketplaceFeatureBannerIcon: css`
1199
+ width: 24px;
1200
+ height: 24px;
1201
+ display: inline-flex;
1202
+ `,
1203
+ pluginMarketplaceFeatureBannerText: css`
1204
+ font-size: 0.95rem;
1205
+ color: ${t('rgba(255, 255, 255, 0.95)', 'rgba(255, 255, 255, 0.9)')};
1206
+ line-height: 1.5;
1207
+ margin: 0;
1208
+ `,
1209
+ pluginMarketplaceFeatureBannerButton: css`
1210
+ display: inline-flex;
1211
+ align-items: center;
1212
+ gap: 0.5rem;
1213
+ padding: 0.625rem 1.25rem;
1214
+ background: white;
1215
+ color: ${colors.blue[600]};
1216
+ font-weight: 600;
1217
+ font-size: 0.95rem;
1218
+ border-radius: 0.5rem;
1219
+ border: none;
1220
+ cursor: pointer;
1221
+ transition: all 0.2s ease;
1222
+ text-decoration: none;
1223
+ align-self: flex-start;
1224
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
1225
+
1226
+ &:hover {
1227
+ background: ${t(colors.gray[50], colors.gray[100])};
1228
+ transform: translateY(-1px);
1229
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
1230
+ }
1231
+
1232
+ &:active {
1233
+ transform: translateY(0);
1234
+ }
1235
+ `,
1236
+ pluginMarketplaceFeatureBannerButtonIcon: css`
1237
+ width: 18px;
1238
+ height: 18px;
1239
+ `,
1171
1240
  pluginMarketplaceCardDisabled: css`
1172
1241
  opacity: 0.6;
1173
1242
  filter: grayscale(0.3);
@@ -12,6 +12,31 @@ interface PluginSectionComponentProps {
12
12
  onCardAction: (card: PluginCard) => void
13
13
  }
14
14
 
15
+ const StarIcon = () => (
16
+ <svg
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ viewBox="0 0 24 24"
19
+ fill="currentColor"
20
+ >
21
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
22
+ </svg>
23
+ )
24
+
25
+ const MailIcon = () => (
26
+ <svg
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ stroke-width="2"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round"
34
+ >
35
+ <rect x="2" y="4" width="20" height="16" rx="2" />
36
+ <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
37
+ </svg>
38
+ )
39
+
15
40
  export const PluginSectionComponent = (props: PluginSectionComponentProps) => {
16
41
  const styles = useStyles()
17
42
 
@@ -38,6 +63,33 @@ export const PluginSectionComponent = (props: PluginSectionComponentProps) => {
38
63
  </div>
39
64
 
40
65
  <Show when={!props.isCollapsed()}>
66
+ <Show when={props.section.id === 'featured'}>
67
+ <div class={styles().pluginMarketplaceFeatureBanner}>
68
+ <div class={styles().pluginMarketplaceFeatureBannerContent}>
69
+ <h4 class={styles().pluginMarketplaceFeatureBannerTitle}>
70
+ <span class={styles().pluginMarketplaceFeatureBannerIcon}>
71
+ <StarIcon />
72
+ </span>
73
+ Want to be featured here?
74
+ </h4>
75
+ <p class={styles().pluginMarketplaceFeatureBannerText}>
76
+ If you've built a plugin for TanStack Devtools and would like to
77
+ showcase it in the featured section, we'd love to hear from you!
78
+ Reach out to us to discuss partnership opportunities.
79
+ </p>
80
+ <a
81
+ href="mailto:partners+devtools@tanstack.com?subject=Featured%20Plugin%20Partnership%20Inquiry"
82
+ class={styles().pluginMarketplaceFeatureBannerButton}
83
+ >
84
+ <span class={styles().pluginMarketplaceFeatureBannerButtonIcon}>
85
+ <MailIcon />
86
+ </span>
87
+ Contact Us
88
+ </a>
89
+ </div>
90
+ </div>
91
+ </Show>
92
+
41
93
  <div class={styles().pluginMarketplaceGrid}>
42
94
  <For each={props.section.cards}>
43
95
  {(card) => (
@@ -223,10 +223,11 @@ describe('groupIntoSections', () => {
223
223
 
224
224
  const sections = groupIntoSections(cards)
225
225
 
226
- expect(sections).toHaveLength(1)
227
- expect(sections[0]?.id).toBe('active')
228
- expect(sections[0]?.displayName).toBe('✓ Active Plugins')
229
- expect(sections[0]?.cards).toHaveLength(1)
226
+ expect(sections).toHaveLength(2) // Featured (always present) + Active
227
+ expect(sections[0]?.id).toBe('featured')
228
+ expect(sections[1]?.id).toBe('active')
229
+ expect(sections[1]?.displayName).toBe('✓ Active Plugins')
230
+ expect(sections[1]?.cards).toHaveLength(1)
230
231
  })
231
232
 
232
233
  it('should group featured plugins', () => {
@@ -266,9 +267,11 @@ describe('groupIntoSections', () => {
266
267
 
267
268
  const sections = groupIntoSections(cards)
268
269
 
269
- expect(sections).toHaveLength(1)
270
- expect(sections[0]?.id).toBe('active')
271
- expect(sections.find((s) => s.id === 'featured')).toBeUndefined()
270
+ expect(sections).toHaveLength(2) // Featured (always present) + Active
271
+ expect(sections[0]?.id).toBe('featured')
272
+ expect(sections[1]?.id).toBe('active')
273
+ expect(sections[0]?.cards).toHaveLength(0) // Featured section empty
274
+ expect(sections[1]?.cards).toHaveLength(1) // Active has the plugin
272
275
  })
273
276
 
274
277
  it('should group available plugins', () => {
@@ -286,9 +289,10 @@ describe('groupIntoSections', () => {
286
289
 
287
290
  const sections = groupIntoSections(cards)
288
291
 
289
- expect(sections).toHaveLength(1)
290
- expect(sections[0]?.id).toBe('available')
291
- expect(sections[0]?.displayName).toBe('Available Plugins')
292
+ expect(sections).toHaveLength(2) // Featured (always present) + Available
293
+ expect(sections[0]?.id).toBe('featured')
294
+ expect(sections[1]?.id).toBe('available')
295
+ expect(sections[1]?.displayName).toBe('Available Plugins')
292
296
  })
293
297
 
294
298
  it('should not include featured plugins in available section', () => {
@@ -345,8 +349,8 @@ describe('groupIntoSections', () => {
345
349
  const sections = groupIntoSections(cards)
346
350
 
347
351
  expect(sections).toHaveLength(3)
348
- expect(sections[0]?.id).toBe('active')
349
- expect(sections[1]?.id).toBe('featured')
352
+ expect(sections[0]?.id).toBe('featured')
353
+ expect(sections[1]?.id).toBe('active')
350
354
  expect(sections[2]?.id).toBe('available')
351
355
  })
352
356
 
@@ -365,12 +369,16 @@ describe('groupIntoSections', () => {
365
369
 
366
370
  const sections = groupIntoSections(cards)
367
371
 
368
- expect(sections).toHaveLength(0)
372
+ expect(sections).toHaveLength(1) // Only featured section (always present, empty)
373
+ expect(sections[0]?.id).toBe('featured')
374
+ expect(sections[0]?.cards).toHaveLength(0)
369
375
  })
370
376
 
371
377
  it('should handle empty card array', () => {
372
378
  const sections = groupIntoSections([])
373
- expect(sections).toHaveLength(0)
379
+ expect(sections).toHaveLength(1) // Featured section always present
380
+ expect(sections[0]?.id).toBe('featured')
381
+ expect(sections[0]?.cards).toHaveLength(0)
374
382
  })
375
383
  })
376
384
 
@@ -201,6 +201,20 @@ export const groupIntoSections = (
201
201
  ): Array<PluginSection> => {
202
202
  const sections: Array<PluginSection> = []
203
203
 
204
+ // Add Featured section first - always show this section
205
+ const featuredCards = allCards.filter(
206
+ (c) =>
207
+ c.metadata?.featured &&
208
+ c.actionType !== 'already-installed' &&
209
+ c.isCurrentFramework, // Only show featured plugins for current framework
210
+ )
211
+ // Always add featured section, even if no cards to show the partner banner
212
+ sections.push({
213
+ id: 'featured',
214
+ displayName: '⭐ Featured',
215
+ cards: featuredCards,
216
+ })
217
+
204
218
  // Add Active Plugins section
205
219
  const activeCards = allCards.filter(
206
220
  (c) => c.actionType === 'already-installed' && c.isRegistered,
@@ -213,21 +227,6 @@ export const groupIntoSections = (
213
227
  })
214
228
  }
215
229
 
216
- // Add Featured section
217
- const featuredCards = allCards.filter(
218
- (c) =>
219
- c.metadata?.featured &&
220
- c.actionType !== 'already-installed' &&
221
- c.isCurrentFramework, // Only show featured plugins for current framework
222
- )
223
- if (featuredCards.length > 0) {
224
- sections.push({
225
- id: 'featured',
226
- displayName: '⭐ Featured',
227
- cards: featuredCards,
228
- })
229
- }
230
-
231
230
  // Add Available section - all plugins for current framework (TanStack + third-party)
232
231
  const availableCards = allCards.filter(
233
232
  (c) =>
@@ -212,6 +212,35 @@ const PLUGIN_REGISTRY: Record<string, PluginMetadata> = {
212
212
  // THIRD-PARTY PLUGINS - Examples
213
213
  // ==========================================
214
214
  // External contributors can add their plugins below!
215
+
216
+ // Dimano — Prefetch Heatmap for TanStack Router
217
+ '@dimano/ts-devtools-plugin-prefetch-heatmap': {
218
+ packageName: '@dimano/ts-devtools-plugin-prefetch-heatmap',
219
+ title: 'Prefetch Heatmap',
220
+ description:
221
+ 'Visualize TanStack Router prefetch intent, hits, and waste with a color overlay and a live metrics panel.',
222
+ requires: {
223
+ packageName: '@tanstack/react-router',
224
+ minVersion: '1.0.0',
225
+ },
226
+ // default export registers the plugin
227
+ pluginImport: {
228
+ importName: 'registerPrefetchHeatmapPlugin',
229
+ type: 'function',
230
+ },
231
+ // helps the host match your plugin deterministically
232
+ pluginId: 'prefetch-heatmap',
233
+ // show a nice card in the marketplace
234
+ logoUrl:
235
+ 'https://raw.githubusercontent.com/dimitrianoudi/tanstack-prefetch-heatmap/main/assets/prefetch-heatmap-card.png',
236
+ docsUrl:
237
+ 'https://github.com/dimitrianoudi/tanstack-prefetch-heatmap#prefetch-heatmap-devtools-plugin',
238
+ repoUrl: 'https://github.com/dimitrianoudi/tanstack-prefetch-heatmap',
239
+ author: 'Dimitris Anoudis (@dimitrianoudi)',
240
+ framework: 'react',
241
+ isNew: true,
242
+ tags: ['Router', 'Prefetch', 'Analytics', 'Overlay', 'TanStack'],
243
+ },
215
244
  }
216
245
 
217
246
  /**
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Maximum number of plugins that can be active simultaneously in the devtools
3
+ */
4
+ export const MAX_ACTIVE_PLUGINS = 3
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getDefaultActivePlugins } from './get-default-active-plugins'
3
+ import type { PluginWithId } from './get-default-active-plugins'
4
+
5
+ describe('getDefaultActivePlugins', () => {
6
+ it('should return empty array when no plugins provided', () => {
7
+ const result = getDefaultActivePlugins([])
8
+ expect(result).toEqual([])
9
+ })
10
+
11
+ it('should automatically activate a single plugin', () => {
12
+ const plugins: Array<PluginWithId> = [
13
+ {
14
+ id: 'only-plugin',
15
+ },
16
+ ]
17
+
18
+ const result = getDefaultActivePlugins(plugins)
19
+ expect(result).toEqual(['only-plugin'])
20
+ })
21
+
22
+ it('should automatically activate a single plugin even if defaultOpen is false', () => {
23
+ const plugins: Array<PluginWithId> = [
24
+ {
25
+ id: 'only-plugin',
26
+ defaultOpen: false,
27
+ },
28
+ ]
29
+
30
+ const result = getDefaultActivePlugins(plugins)
31
+ expect(result).toEqual(['only-plugin'])
32
+ })
33
+
34
+ it('should return empty array when multiple plugins without defaultOpen', () => {
35
+ const plugins: Array<PluginWithId> = [
36
+ {
37
+ id: 'plugin1',
38
+ },
39
+ {
40
+ id: 'plugin2',
41
+ },
42
+ {
43
+ id: 'plugin3',
44
+ },
45
+ ]
46
+
47
+ const result = getDefaultActivePlugins(plugins)
48
+ expect(result).toEqual([])
49
+ })
50
+
51
+ it('should activate plugins with defaultOpen: true', () => {
52
+ const plugins: Array<PluginWithId> = [
53
+ {
54
+ id: 'plugin1',
55
+ defaultOpen: true,
56
+ },
57
+ {
58
+ id: 'plugin2',
59
+ defaultOpen: false,
60
+ },
61
+ {
62
+ id: 'plugin3',
63
+ defaultOpen: true,
64
+ },
65
+ ]
66
+
67
+ const result = getDefaultActivePlugins(plugins)
68
+ expect(result).toEqual(['plugin1', 'plugin3'])
69
+ })
70
+
71
+ it('should limit defaultOpen plugins to MAX_ACTIVE_PLUGINS (3)', () => {
72
+ const plugins: Array<PluginWithId> = [
73
+ {
74
+ id: 'plugin1',
75
+ defaultOpen: true,
76
+ },
77
+ {
78
+ id: 'plugin2',
79
+ defaultOpen: true,
80
+ },
81
+ {
82
+ id: 'plugin3',
83
+ defaultOpen: true,
84
+ },
85
+ {
86
+ id: 'plugin4',
87
+ defaultOpen: true,
88
+ },
89
+ {
90
+ id: 'plugin5',
91
+ defaultOpen: true,
92
+ },
93
+ ]
94
+
95
+ const result = getDefaultActivePlugins(plugins)
96
+ // Should only return first 3
97
+ expect(result).toEqual(['plugin1', 'plugin2', 'plugin3'])
98
+ expect(result.length).toBe(3)
99
+ })
100
+
101
+ it('should activate exactly MAX_ACTIVE_PLUGINS when that many have defaultOpen', () => {
102
+ const plugins: Array<PluginWithId> = [
103
+ {
104
+ id: 'plugin1',
105
+ defaultOpen: true,
106
+ },
107
+ {
108
+ id: 'plugin2',
109
+ defaultOpen: true,
110
+ },
111
+ {
112
+ id: 'plugin3',
113
+ defaultOpen: true,
114
+ },
115
+ {
116
+ id: 'plugin4',
117
+ defaultOpen: false,
118
+ },
119
+ ]
120
+
121
+ const result = getDefaultActivePlugins(plugins)
122
+ expect(result).toEqual(['plugin1', 'plugin2', 'plugin3'])
123
+ expect(result.length).toBe(3)
124
+ })
125
+
126
+ it('should handle mix of defaultOpen true/false/undefined', () => {
127
+ const plugins: Array<PluginWithId> = [
128
+ {
129
+ id: 'plugin1',
130
+ defaultOpen: true,
131
+ },
132
+ {
133
+ id: 'plugin2',
134
+ // undefined defaultOpen
135
+ },
136
+ {
137
+ id: 'plugin3',
138
+ defaultOpen: false,
139
+ },
140
+ {
141
+ id: 'plugin4',
142
+ defaultOpen: true,
143
+ },
144
+ ]
145
+
146
+ const result = getDefaultActivePlugins(plugins)
147
+ // Only plugin1 and plugin4 have defaultOpen: true
148
+ expect(result).toEqual(['plugin1', 'plugin4'])
149
+ })
150
+
151
+ it('should return single plugin even if it has defaultOpen: true', () => {
152
+ const plugins: Array<PluginWithId> = [
153
+ {
154
+ id: 'only-plugin',
155
+ defaultOpen: true,
156
+ },
157
+ ]
158
+
159
+ const result = getDefaultActivePlugins(plugins)
160
+ expect(result).toEqual(['only-plugin'])
161
+ })
162
+
163
+ it('should stop at MAX_ACTIVE_PLUGINS limit when 5 plugins have defaultOpen: true', () => {
164
+ const plugins: Array<PluginWithId> = [
165
+ {
166
+ id: 'plugin1',
167
+ defaultOpen: true,
168
+ },
169
+ {
170
+ id: 'plugin2',
171
+ defaultOpen: true,
172
+ },
173
+ {
174
+ id: 'plugin3',
175
+ defaultOpen: true,
176
+ },
177
+ {
178
+ id: 'plugin4',
179
+ defaultOpen: true,
180
+ },
181
+ {
182
+ id: 'plugin5',
183
+ defaultOpen: true,
184
+ },
185
+ ]
186
+
187
+ const result = getDefaultActivePlugins(plugins)
188
+ // Should only activate the first 3, plugin4 and plugin5 should be ignored
189
+ expect(result).toEqual(['plugin1', 'plugin2', 'plugin3'])
190
+ expect(result.length).toBe(3)
191
+ expect(result).not.toContain('plugin4')
192
+ expect(result).not.toContain('plugin5')
193
+ })
194
+ })
@@ -0,0 +1,36 @@
1
+ import { MAX_ACTIVE_PLUGINS } from './constants'
2
+
3
+ export interface PluginWithId {
4
+ id: string
5
+ defaultOpen?: boolean
6
+ }
7
+
8
+ /**
9
+ * Determines which plugins should be active by default when no plugins are currently active.
10
+ *
11
+ * Rules:
12
+ * 1. If there's only 1 plugin, activate it automatically
13
+ * 2. If there are multiple plugins, activate those with defaultOpen: true (up to MAX_ACTIVE_PLUGINS limit)
14
+ * 3. If no plugins have defaultOpen: true, return empty array
15
+ *
16
+ * @param plugins - Array of plugins with IDs
17
+ * @returns Array of plugin IDs that should be active by default
18
+ */
19
+ export function getDefaultActivePlugins(
20
+ plugins: Array<PluginWithId>,
21
+ ): Array<string> {
22
+ if (plugins.length === 0) {
23
+ return []
24
+ }
25
+
26
+ // If there's only 1 plugin, activate it automatically
27
+ if (plugins.length === 1) {
28
+ return [plugins[0]!.id]
29
+ }
30
+
31
+ // Otherwise, activate plugins with defaultOpen: true (up to the limit)
32
+ return plugins
33
+ .filter((plugin) => plugin.defaultOpen === true)
34
+ .slice(0, MAX_ACTIVE_PLUGINS)
35
+ .map((plugin) => plugin.id)
36
+ }