@dimina-kit/inspect 0.3.0 → 0.4.0-dev.20260716163758

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,202 @@
1
+ /**
2
+ * Unit tests for `useActiveBridgeId`.
3
+ *
4
+ * Validates the synchronous-derivation contract:
5
+ * - `activeBridgeId` must be correct on the **same render** as `bridges`
6
+ * changes — no useEffect lag allowed.
7
+ * - Tracks the last newly-added bridge id automatically.
8
+ * - Manual selection via `setActiveBridge` is respected across rerenders
9
+ * (while no new bridge id appears).
10
+ * - A newly-added bridge always overrides a prior manual selection.
11
+ * - If the selected bridge id is removed, falls back to the last bridge.
12
+ * - Calling `setActiveBridge` with an id not in `bridges` is ignored.
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import { renderHook, act } from '@testing-library/react'
16
+
17
+ import { useActiveBridgeId } from './use-active-bridge-id.js'
18
+
19
+ const A = { id: 'A', pagePath: '/a' }
20
+ const B = { id: 'B', pagePath: '/b' }
21
+ const C = { id: 'C', pagePath: '/c' }
22
+
23
+ describe('useActiveBridgeId — synchronous derivation contract', () => {
24
+ // Step 1: initial empty bridges → activeBridgeId is null
25
+ it('step 1: returns null when bridges is empty', () => {
26
+ const { result } = renderHook(() => useActiveBridgeId([]))
27
+ // Must be correct synchronously — no await / flush needed
28
+ expect(result.current.activeBridgeId).toBeNull()
29
+ })
30
+
31
+ // Step 2: first bridge appears → activeBridgeId follows immediately
32
+ it('step 2: tracks the only bridge synchronously on rerender', () => {
33
+ const { result, rerender } = renderHook(
34
+ ({ bridges }) => useActiveBridgeId(bridges),
35
+ { initialProps: { bridges: [] as typeof A[] } },
36
+ )
37
+ expect(result.current.activeBridgeId).toBeNull()
38
+
39
+ rerender({ bridges: [A] })
40
+ // Synchronous check — if the impl uses useEffect, this will still be null → red
41
+ expect(result.current.activeBridgeId).toBe('A')
42
+ })
43
+
44
+ // Step 3: new bridge B is appended → activeBridgeId follows the newest (last) id
45
+ it('step 3: follows the newest bridge when a new id is added', () => {
46
+ const { result, rerender } = renderHook(
47
+ ({ bridges }) => useActiveBridgeId(bridges),
48
+ { initialProps: { bridges: [] as Array<{ id: string; pagePath: string | null }> } },
49
+ )
50
+
51
+ rerender({ bridges: [A] })
52
+ expect(result.current.activeBridgeId).toBe('A')
53
+
54
+ rerender({ bridges: [A, B] })
55
+ // B is new — must be selected synchronously
56
+ expect(result.current.activeBridgeId).toBe('B')
57
+ })
58
+
59
+ // Step 4 + 5: manual setActiveBridge is respected and persists across rerenders
60
+ // without new bridge ids
61
+ it('step 4+5: setActiveBridge selects a valid id and persists across rerenders with no new bridges', () => {
62
+ const { result, rerender } = renderHook(
63
+ ({ bridges }) => useActiveBridgeId(bridges),
64
+ { initialProps: { bridges: [A, B] } },
65
+ )
66
+ // After initial render with [A, B], B is last → selected
67
+ expect(result.current.activeBridgeId).toBe('B')
68
+
69
+ // Step 4: manual select A
70
+ act(() => {
71
+ result.current.setActiveBridge('A')
72
+ })
73
+ expect(result.current.activeBridgeId).toBe('A')
74
+
75
+ // Step 5: rerender with the same ids — no new bridge → manual selection persists
76
+ rerender({ bridges: [A, B] })
77
+ expect(result.current.activeBridgeId).toBe('A')
78
+ })
79
+
80
+ // Step 6: selected bridge is removed → falls back to last bridge
81
+ it('step 6: falls back to last bridge when the selected bridge is removed', () => {
82
+ const { result, rerender } = renderHook(
83
+ ({ bridges }) => useActiveBridgeId(bridges),
84
+ { initialProps: { bridges: [A, B] } },
85
+ )
86
+
87
+ // Manually select A
88
+ act(() => {
89
+ result.current.setActiveBridge('A')
90
+ })
91
+ expect(result.current.activeBridgeId).toBe('A')
92
+
93
+ // Remove A — only B remains
94
+ rerender({ bridges: [B] })
95
+ expect(result.current.activeBridgeId).toBe('B')
96
+ })
97
+
98
+ // Step 7: new bridge C appears even when A was manually selected → C takes over
99
+ it('step 7: new bridge overrides prior manual selection synchronously', () => {
100
+ const { result, rerender } = renderHook(
101
+ ({ bridges }) => useActiveBridgeId(bridges),
102
+ { initialProps: { bridges: [A, B] } },
103
+ )
104
+
105
+ // Manual select A
106
+ act(() => {
107
+ result.current.setActiveBridge('A')
108
+ })
109
+ expect(result.current.activeBridgeId).toBe('A')
110
+
111
+ // C is a newly-added bridge id → must override manual selection
112
+ rerender({ bridges: [A, B, C] })
113
+ // Synchronous check — C must be selected in this render, not a future one
114
+ expect(result.current.activeBridgeId).toBe('C')
115
+ })
116
+
117
+ // Extra: setActiveBridge with unknown id is silently ignored
118
+ it('ignores setActiveBridge calls with an id not present in bridges', () => {
119
+ const { result } = renderHook(() => useActiveBridgeId([A, B]))
120
+ // Default: B (last)
121
+ expect(result.current.activeBridgeId).toBe('B')
122
+
123
+ act(() => {
124
+ result.current.setActiveBridge('DOES_NOT_EXIST')
125
+ })
126
+ // Still B — invalid id was ignored
127
+ expect(result.current.activeBridgeId).toBe('B')
128
+ })
129
+ })
130
+
131
+ describe('useActiveBridgeId — follows the simulator active page', () => {
132
+ // Auto-follow targets the page on screen, NOT just the last-inited bridge.
133
+ it('selects the bridge matching activePagePath instead of the last bridge', () => {
134
+ // B is last, but the active page is A → A wins.
135
+ const { result } = renderHook(() => useActiveBridgeId([A, B], '/a'))
136
+ expect(result.current.activeBridgeId).toBe('A')
137
+ })
138
+
139
+ // Switching a tabBar tab re-inits no bridge; only activePagePath changes.
140
+ it('re-follows when activePagePath changes with the same bridge set', () => {
141
+ const { result, rerender } = renderHook(
142
+ ({ active }) => useActiveBridgeId([A, B, C], active),
143
+ { initialProps: { active: '/a' } },
144
+ )
145
+ expect(result.current.activeBridgeId).toBe('A')
146
+
147
+ rerender({ active: '/c' })
148
+ expect(result.current.activeBridgeId).toBe('C')
149
+ })
150
+
151
+ // A tab switch overrides a stale manual pick — the panel returns to the page
152
+ // the user is now looking at.
153
+ it('drops a manual pick when the active page changes', () => {
154
+ const { result, rerender } = renderHook(
155
+ ({ active }) => useActiveBridgeId([A, B, C], active),
156
+ { initialProps: { active: '/a' } },
157
+ )
158
+ act(() => {
159
+ result.current.setActiveBridge('B')
160
+ })
161
+ expect(result.current.activeBridgeId).toBe('B')
162
+
163
+ // User switches the simulator to page C → follow C, not the pinned B.
164
+ rerender({ active: '/c' })
165
+ expect(result.current.activeBridgeId).toBe('C')
166
+ })
167
+
168
+ // While the active page is unchanged, a manual pick is still respected.
169
+ it('keeps a manual pick while activePagePath is unchanged', () => {
170
+ const { result, rerender } = renderHook(
171
+ ({ active }) => useActiveBridgeId([A, B, C], active),
172
+ { initialProps: { active: '/a' } },
173
+ )
174
+ act(() => {
175
+ result.current.setActiveBridge('B')
176
+ })
177
+ expect(result.current.activeBridgeId).toBe('B')
178
+
179
+ rerender({ active: '/a' })
180
+ expect(result.current.activeBridgeId).toBe('B')
181
+ })
182
+
183
+ // pagePath comparison tolerates a leading slash on either side.
184
+ it('matches activePagePath regardless of a leading slash', () => {
185
+ const bareA = { id: 'A', pagePath: 'pages/a/a' }
186
+ const bareB = { id: 'B', pagePath: 'pages/b/b' }
187
+ const { result } = renderHook(() => useActiveBridgeId([bareA, bareB], '/pages/a/a'))
188
+ expect(result.current.activeBridgeId).toBe('A')
189
+ })
190
+
191
+ // No active page / no matching bridge → keep the legacy newest-bridge default.
192
+ it('falls back to the last bridge when the active page is empty or unmatched', () => {
193
+ const { result, rerender } = renderHook(
194
+ ({ active }) => useActiveBridgeId([A, B], active),
195
+ { initialProps: { active: '' } },
196
+ )
197
+ expect(result.current.activeBridgeId).toBe('B')
198
+
199
+ rerender({ active: '/zzz' })
200
+ expect(result.current.activeBridgeId).toBe('B')
201
+ })
202
+ })
@@ -0,0 +1,71 @@
1
+ import { useState } from 'react'
2
+
3
+ function normalizePagePath(p: string | null | undefined): string {
4
+ return (p ?? '').replace(/^\/+/, '')
5
+ }
6
+
7
+ /**
8
+ * Derive which AppData page tab is active, synchronously.
9
+ *
10
+ * `activeBridgeId` is a pure derivation of (current bridges, the simulator's
11
+ * active page, manual pick), computed during render — so it is never stale for
12
+ * a frame the way a useEffect-derived value would be. Auto-follow tracks the
13
+ * page the user is actually looking at: when `activePagePath` matches a
14
+ * bridge's pagePath the panel snaps to it, so switching tabBar tabs (which
15
+ * re-inits no bridge) still moves the panel. A brand-new bridge id appearing or
16
+ * the active page changing both drop a prior manual pick back to auto-follow.
17
+ */
18
+ export function useActiveBridgeId(
19
+ bridges: ReadonlyArray<{ id: string; pagePath: string | null }>,
20
+ activePagePath = '',
21
+ ): { activeBridgeId: string | null; setActiveBridge: (id: string) => void } {
22
+ const ids = bridges.map((b) => b.id)
23
+ // NUL separator: ids are runtime-generated and never contain it, so two
24
+ // distinct id lists can never collide on the same key.
25
+ const idsKey = ids.join('\x00')
26
+
27
+ // The user's manual tab pick; null means "auto-follow the active page".
28
+ const [selectedBridgeId, setSelectedBridgeId] = useState<string | null>(null)
29
+ // The id list from the previous render — lets us spot a freshly-inited page.
30
+ const [prevIdsKey, setPrevIdsKey] = useState('')
31
+ // The active page from the previous render — lets us spot a tab switch.
32
+ const [prevActivePath, setPrevActivePath] = useState(activePagePath)
33
+
34
+ if (idsKey !== prevIdsKey) {
35
+ // The bridge set changed. If a never-before-seen id appeared, a new page
36
+ // inited → drop back to auto-follow. Adjusting state during render is the
37
+ // React-sanctioned alternative to an effect: React re-renders synchronously
38
+ // before the commit, so `activeBridgeId` below is correct this frame.
39
+ const prevIds = prevIdsKey ? prevIdsKey.split('\x00') : []
40
+ if (ids.some((id) => !prevIds.includes(id))) {
41
+ setSelectedBridgeId(null)
42
+ }
43
+ setPrevIdsKey(idsKey)
44
+ }
45
+
46
+ if (activePagePath !== prevActivePath) {
47
+ // The simulator navigated to a different active page (e.g. a tabBar switch).
48
+ // Drop the manual pick so the panel re-follows the page on screen — without
49
+ // this, switching tabs leaves the panel stuck on whichever page inited last.
50
+ setSelectedBridgeId(null)
51
+ setPrevActivePath(activePagePath)
52
+ }
53
+
54
+ // Auto-follow target: the bridge whose pagePath matches the active page.
55
+ const activeFollowId = activePagePath
56
+ ? bridges.find((b) => normalizePagePath(b.pagePath) === normalizePagePath(activePagePath))?.id ?? null
57
+ : null
58
+
59
+ const activeBridgeId
60
+ = selectedBridgeId && ids.includes(selectedBridgeId)
61
+ ? selectedBridgeId
62
+ // Prefer the active page; fall back to the newest bridge when the active
63
+ // page is unknown or has no matching bridge yet.
64
+ : (activeFollowId ?? ids.at(-1) ?? null)
65
+
66
+ const setActiveBridge = (id: string): void => {
67
+ if (ids.includes(id)) setSelectedBridgeId(id)
68
+ }
69
+
70
+ return { activeBridgeId, setActiveBridge }
71
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveTagName } from './wxml-extract.js'
3
+ import type { ComponentInstance } from './wxml-extract.js'
4
+
5
+ function instance(type: Record<string, unknown>, extra: Record<string, unknown> = {}): ComponentInstance {
6
+ return { type, ...extra } as ComponentInstance
7
+ }
8
+
9
+ describe('resolveTagName: authoritative Vue name = source path', () => {
10
+ it('surfaces a custom component whose name is its full module path', () => {
11
+ // The render runtime sets `name: componentPath` on every usingComponents
12
+ // entry, so the panel tags the node with its source path (WeChat parity).
13
+ expect(resolveTagName(instance({ name: '/components/ubt/index' }))).toBe('components/ubt/index')
14
+ })
15
+
16
+ it('surfaces a page whose name is its module path', () => {
17
+ expect(resolveTagName(instance({ name: 'pages/tab/home/index' }))).toBe('pages/tab/home/index')
18
+ })
19
+
20
+ it('strips the leading slash so tags never start with `/`', () => {
21
+ expect(resolveTagName(instance({ name: '/pages/index/index' }))).toBe('pages/index/index')
22
+ })
23
+
24
+ it('accepts the path off `__name` when `name` is absent', () => {
25
+ expect(resolveTagName(instance({ __name: '/node-modules/pickpic/thumbnail/image' })))
26
+ .toBe('node-modules/pickpic/thumbnail/image')
27
+ })
28
+
29
+ it('resolves from `type.name` alone, without any provides chain', () => {
30
+ // Robustness over the legacy provide/inject reconstruction, which needed a
31
+ // live parent provides chain; the per-type name carries the path directly.
32
+ expect(resolveTagName(instance({ name: '/components/card/card' }, { parent: undefined, provides: undefined })))
33
+ .toBe('components/card/card')
34
+ })
35
+ })
36
+
37
+ describe('resolveTagName: non-path names keep the legacy fallback behavior', () => {
38
+ it('kebab-cases a built-in PascalCase name (no slash → not a source path)', () => {
39
+ expect(resolveTagName(instance({ name: 'ScrollView' }))).toBe('scroll-view')
40
+ })
41
+
42
+ it('does NOT mislabel a nameless `__scopeId` wrapper as a second page', () => {
43
+ // A Taro template wrapper (`taro_tmpl`/`tmpl_0_3`) carries `__scopeId` but no
44
+ // path-name and is not a page; it must fall back to `template`, never `page`.
45
+ const wrapper = instance(
46
+ { __scopeId: 'data-v-abc' },
47
+ { parent: { type: { components: {} } }, appContext: { components: {} } },
48
+ )
49
+ expect(resolveTagName(wrapper)).not.toBe('page')
50
+ expect(resolveTagName(wrapper)).toBe('template')
51
+ })
52
+
53
+ it('no longer reconstructs a path from the provides chain (heuristic removed)', () => {
54
+ // Identity now comes solely from the authoritative `name`. An instance that
55
+ // only carries the legacy signals (proxy.__page__ + provides.path, no
56
+ // path-like name) is NOT upgraded to its path — it falls back like any
57
+ // nameless __scopeId wrapper. Real pages/components always carry `name`.
58
+ const legacy = instance(
59
+ { __scopeId: 'data-v-x' },
60
+ {
61
+ proxy: { __page__: true },
62
+ provides: { path: '/pages/legacy/legacy' },
63
+ parent: { type: { components: {} } },
64
+ appContext: { components: {} },
65
+ },
66
+ )
67
+ expect(resolveTagName(legacy)).not.toBe('pages/legacy/legacy')
68
+ expect(resolveTagName(legacy)).toBe('template')
69
+ })
70
+ })
@@ -55,71 +55,25 @@ function pascalToKebab(name: string): string {
55
55
  return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
56
56
  }
57
57
 
58
- /**
59
- * Read the page's source path off Vue's provide/inject. dimina runtime.js 在
60
- * dd-page 的 setup() 里调用 `provide('path', path)`,Vue 把它存到
61
- * `instance.provides`。我们利用这个把页面节点的 tagName 从硬编码 `page`
62
- * 升级为页面全路径(如 `pages/index/index`),对齐微信开发者工具。
63
- */
64
- function resolvePagePath(instance: ComponentInstance): string | null {
65
- // dimina runtime 在 dd-page 的 setup 里 `instance.proxy.__page__ = true` 并
66
- // `provide('path', path)`。两个标记同时具备才视为页面层级,避免 dd-page
67
- // 的子节点继承 provides.path 后被误判(Vue 用 Object.create 链式 provides)。
68
- const proxy = (instance as Record<string, unknown>).proxy as
69
- | Record<string, unknown>
70
- | undefined
71
- if (proxy?.__page__ !== true) return null
72
- const provides = (instance as Record<string, unknown>).provides as
73
- | Record<string, unknown>
74
- | undefined
75
- const path = provides?.path
76
- if (typeof path !== 'string' || !path) return null
77
- return path.startsWith('/') ? path.slice(1) : path
78
- }
79
-
80
- /**
81
- * A NATIVE dimina custom component (a `usingComponents` entry) calls
82
- * `provide('path', componentPath)` in its setup (render runtime), so its
83
- * provided `path` DIFFERS from its parent's. A plain descendant inherits the
84
- * same provides object (identical `path`) and is not a boundary; a Taro template
85
- * wrapper (`dd-tpl-*`) never provides, so it never differs either. Comparing
86
- * against the parent's provided path therefore isolates exactly the native
87
- * component roots — matching WeChat, which shows each custom component as a node
88
- * tagged with its full registered path (with a synthetic `#shadow-root` added by
89
- * `wrapInShadowRoot` since the path contains `/`). Pages are handled earlier via
90
- * their authoritative `__page__` marker, so this only fires for components.
91
- */
92
- function resolveComponentPath(instance: ComponentInstance): string | null {
93
- const provides = (instance as Record<string, unknown>).provides as Record<string, unknown> | undefined
94
- const path = provides?.path
95
- if (typeof path !== 'string' || !path) return null
96
- const parent = instance.parent as Record<string, unknown> | undefined
97
- const parentProvides = parent?.provides as Record<string, unknown> | undefined
98
- if (path === parentProvides?.path) return null
99
- return path.startsWith('/') ? path.slice(1) : path
100
- }
101
-
102
- function resolveTagName(instance: ComponentInstance): string {
58
+ export function resolveTagName(instance: ComponentInstance): string {
103
59
  const type = instance.type
104
60
  if (!type) return 'unknown'
105
- // 页面层级优先:dd-page 没有 __tagName/__name,且 home 页等无 usingComponents
106
- // 的页面 type.components undefined,会落到 resolveTemplateNameFromParent
107
- // 回到 'page'。在那之前直接用 provide('path') 的路径升级 tag 名。
108
- const pagePath = resolvePagePath(instance)
109
- if (pagePath) return pagePath
110
- // Native custom-component boundary tag with its full registered path (WeChat
111
- // parity). Must precede the __tagName/__name/dd- shortening below, which would
112
- // otherwise collapse `dd-foo` to the bare `foo` and lose the path.
113
- const componentPath = resolveComponentPath(instance)
114
- if (componentPath) return componentPath
61
+ // Authoritative source-path identity: the render runtime sets each compiled
62
+ // page/custom-component's Vue `name` to its miniprogram module path. A name
63
+ // containing `/` IS that source path trust it directly (WeChat parity).
64
+ // It is per-type (not inherited via provides), so a descendant never leaks its
65
+ // ancestor's path, and it needs no live provide/inject chain to reconstruct.
66
+ const nameId = (type.name || type.__name) as string | undefined
67
+ if (nameId && nameId.includes('/')) {
68
+ return nameId.startsWith('/') ? nameId.slice(1) : nameId
69
+ }
115
70
  if (typeof type.__tagName === 'string') return type.__tagName
116
71
  const name = (type.__name || type.name) as string | undefined
117
72
  if (!name) {
118
- // A nameless component carrying a `__scopeId` is a compiled page, custom
119
- // component, or framework template wrapper. The page is already resolved
120
- // above via its authoritative `__page__` marker, so by here it is NOT a
121
- // page recover the registered tag (e.g. a Taro `taro_tmpl`/`tmpl_0_3`
122
- // wrapper) and fall back to `template`. Defaulting to `page` here would
73
+ // A nameless component carrying a `__scopeId` is a framework template
74
+ // wrapper (e.g. a Taro `taro_tmpl`/`tmpl_0_3`) pages and custom components
75
+ // carry a path `name` and are resolved above. Recover the registered tag and
76
+ // fall back to `template`; never default to `page` here, which would
123
77
  // mislabel every such wrapper as a second page root.
124
78
  if (type.__scopeId) return resolveTemplateNameFromParent(instance) || 'template'
125
79
  return 'unknown'
@@ -190,12 +144,35 @@ function getElementSid(instance: ComponentInstance): string | undefined {
190
144
  return registerSyntheticSid(el)
191
145
  }
192
146
 
147
+ /**
148
+ * The absolute component path a `dd-wrapper` carries in its `name` prop/attr, or
149
+ * null. dimina wraps EVERY custom-component template root in
150
+ * `<dd-wrapper name="/components/foo/foo">`; the path always starts with `/`,
151
+ * which a user-authored `name` attr essentially never does.
152
+ */
153
+ function wrapperPathName(instance: ComponentInstance): string | null {
154
+ for (const raw of [instance.props, instance.attrs]) {
155
+ const n = (raw as Record<string, unknown> | undefined)?.name
156
+ if (typeof n === 'string' && n.startsWith('/')) return n
157
+ }
158
+ return null
159
+ }
160
+
193
161
  function isTransparentComponent(instance: ComponentInstance, tagName: string): boolean {
194
162
  if (tagName === 'unknown') return true
195
163
  // A framework template wrapper (Taro `taro_tmpl` / `tmpl_0_3`, whether named
196
164
  // via `props.is` or recovered from its registration) is compiler scaffolding,
197
165
  // not user content — pass its children straight through.
198
166
  if (FRAMEWORK_TEMPLATE_RE.test(tagName)) return true
167
+ // dimina wraps every custom-component template root in a
168
+ // `<dd-wrapper name="/path">`. Under the name-path model the ENCLOSING
169
+ // component instance already carries that same source path as its own node
170
+ // (its Vue `name` IS the path), so surfacing the wrapper too would emit a
171
+ // SECOND identical path node — the doubled `<components/foo/foo>` layer. The
172
+ // wrapper is redundant scaffolding: pass its children through. Gated on a
173
+ // path-shaped `name` so a user-authored `<wrapper name="x">` is never
174
+ // swallowed (dimina paths always start with `/`).
175
+ if (tagName === 'wrapper' && wrapperPathName(instance)) return true
199
176
  if (tagName === 'template') {
200
177
  const props = instance.props as Record<string, unknown> | undefined
201
178
  const is = props?.is as string | undefined
@@ -296,45 +273,6 @@ function extractChildrenFromVNode(vnode: Record<string, unknown> | null | undefi
296
273
  return result
297
274
  }
298
275
 
299
- /**
300
- * Reverse-map Dimina's `<wrapper name="/components/foo/foo">` (used to host
301
- * every user-defined component) back to the source-level tag the user wrote
302
- * in WXML (e.g. `<foo>`).
303
- *
304
- * The wrapper carries the registered component path in its `name` Vue prop.
305
- * By convention (also followed by miniprogram tooling), the registered key is
306
- * the last path segment, optionally stripping a trailing `/index`. We can't
307
- * see the page's `usingComponents` map from inside the Vue instance, so this
308
- * convention-based recovery is best-effort: if the user picked a different
309
- * registration key than the directory name (e.g. `usingComponents:
310
- * { myCounter: '/components/counter/counter' }`), the panel will show
311
- * `counter`, not `myCounter`.
312
- *
313
- * The path heuristic also resolves a name-collision risk: a user-written
314
- * `<counter name="x">` would land in the same `attrs.name` slot as the
315
- * wrapper-internal path. We only unwrap when the value looks like an absolute
316
- * component path (leading `/`), which dimina always emits but a user would
317
- * almost never type as a literal attr.
318
- */
319
- function unwrapCustomComponent(node: WxmlNode): WxmlNode {
320
- if (node.tagName !== 'wrapper') return node
321
- const path = node.attrs?.name
322
- if (typeof path !== 'string' || !path.startsWith('/')) return node
323
- // 去掉前导 `/` 后保留原路径形式(保留所有斜杠,不做 kebab/dash 转换)。
324
- // 仅当路径以 `/index` 结尾且剥离后仍至少剩一段时才剥(`/index` 单独存在则
325
- // 退回 wrapper,避免出现空 tagName)。
326
- const stripped = path.replace(/^\//, '')
327
- const withoutIndex = stripped.endsWith('/index') ? stripped.slice(0, -'/index'.length) : stripped
328
- const recovered = withoutIndex || stripped
329
- if (!recovered || recovered === 'index') return node
330
- const nextAttrs: Record<string, string> = {}
331
- for (const [k, v] of Object.entries(node.attrs)) {
332
- if (k === 'name') continue
333
- nextAttrs[k] = v
334
- }
335
- return { ...node, tagName: recovered, attrs: nextAttrs }
336
- }
337
-
338
276
  /**
339
277
  * 把"用户授权"层级(页面 / 自定义组件,tagName 以路径形式呈现,含 `/`)
340
278
  * 的 children 包一层合成 `#shadow-root`,对齐微信开发者工具:组件本身
@@ -362,5 +300,5 @@ export function walkInstance(instance: ComponentInstance, depth: number): WxmlNo
362
300
  const node: WxmlNode = { tagName, attrs: extractProps(instance), children }
363
301
  const sid = getElementSid(instance)
364
302
  if (sid) node.sid = sid
365
- return wrapInShadowRoot(unwrapCustomComponent(node))
303
+ return wrapInShadowRoot(node)
366
304
  }