@pyreon/vue-compat 0.18.0 → 0.19.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.
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  Portal,
33
33
  pushContext,
34
34
  h as pyreonH,
35
+ Suspense as PyreonSuspense,
35
36
  useContext,
36
37
  } from '@pyreon/core'
37
38
  import {
@@ -42,7 +43,12 @@ import {
42
43
  type Signal,
43
44
  signal,
44
45
  } from '@pyreon/reactivity'
45
- import { mount as pyreonMount } from '@pyreon/runtime-dom'
46
+ import {
47
+ KeepAlive as PyreonKeepAlive,
48
+ mount as pyreonMount,
49
+ Transition as PyreonTransition,
50
+ TransitionGroup as PyreonTransitionGroup,
51
+ } from '@pyreon/runtime-dom'
46
52
  import { getCurrentCtx, getHookIndex } from './jsx-runtime'
47
53
 
48
54
  // ─── Internal symbols ─────────────────────────────────────────────────────────
@@ -918,6 +924,27 @@ interface ComponentOptions<P extends Props = Props> {
918
924
  props?: Record<string, unknown>
919
925
  }
920
926
 
927
+ /**
928
+ * Computes Vue's fallthrough `attrs` — every passed prop that is NOT a
929
+ * declared prop (and not the internal `children` slot payload). When the
930
+ * component declared no props (`declared` undefined) the split is unknowable,
931
+ * so the full props object is returned (honest back-compat — matches the
932
+ * pre-split behavior rather than guessing).
933
+ */
934
+ function splitVueAttrs(
935
+ props: Record<string, unknown>,
936
+ declared: string[] | undefined,
937
+ ): Record<string, unknown> {
938
+ if (!declared) return props
939
+ const declaredSet = new Set(declared)
940
+ const attrs: Record<string, unknown> = {}
941
+ for (const key of Object.keys(props)) {
942
+ if (key === 'children' || declaredSet.has(key)) continue
943
+ attrs[key] = props[key]
944
+ }
945
+ return attrs
946
+ }
947
+
921
948
  /**
922
949
  * Defines a component using Vue 3 Composition API style.
923
950
  * Only supports the `setup()` function — Options API is not supported.
@@ -928,9 +955,15 @@ export function defineComponent<P extends Props = Props>(
928
955
  if (typeof options === 'function') {
929
956
  return options as ComponentFn<P>
930
957
  }
958
+ const declaredProps = options.props ? Object.keys(options.props) : undefined
931
959
  const comp = (props: P) => {
932
960
  // Extract children from props for slots
933
961
  const children = (props as Record<string, unknown>).children as VNodeChild | undefined
962
+ // Publish the declared-prop names on the active render context so the
963
+ // standalone useAttrs() / getCurrentInstance() can compute the Vue
964
+ // declared-vs-fallthrough split (Vue's `attrs` excludes declared props).
965
+ const rc = getCurrentCtx()
966
+ if (rc && declaredProps) rc._declaredProps = declaredProps
934
967
  // Create a minimal SetupContext
935
968
  const setupCtx: SetupContext = {
936
969
  emit: (event: string, ...args: unknown[]) => {
@@ -941,7 +974,7 @@ export function defineComponent<P extends Props = Props>(
941
974
  slots: {
942
975
  default: children !== undefined ? (() => children) : undefined,
943
976
  } as Record<string, (() => VNodeChild) | undefined>,
944
- attrs: props as Record<string, unknown>,
977
+ attrs: splitVueAttrs(props as Record<string, unknown>, declaredProps),
945
978
  }
946
979
  const result = options.setup(props, setupCtx)
947
980
  if (typeof result === 'function') {
@@ -1216,10 +1249,402 @@ export function Teleport(props: {
1216
1249
  }
1217
1250
 
1218
1251
  /**
1219
- * KeepAlive — not supported in Pyreon. Renders children as-is.
1252
+ * KeepAlive — mounts its children once and keeps them alive (state preserved)
1253
+ * even when hidden, instead of destroying/recreating them.
1254
+ *
1255
+ * Wraps `@pyreon/runtime-dom`'s real KeepAlive. Vue's `<KeepAlive>` keeps a
1256
+ * cache of inactive component instances; this maps the common single-slot
1257
+ * usage to Pyreon's `active`-accessor model.
1258
+ *
1259
+ * LIMITATIONS vs Vue 3:
1260
+ * - Vue's `include` / `exclude` / `max` props are NOT supported. Pyreon's
1261
+ * KeepAlive is a single always-mounted slot toggled by an `active`
1262
+ * accessor — there is no per-component LRU cache to filter or bound.
1263
+ * These props are accepted (so existing Vue code typechecks) but ignored.
1264
+ * - Vue toggles activation via the dynamic child (`<component :is>`); here
1265
+ * you pass an `active` accessor (`() => boolean`). When omitted, children
1266
+ * are always mounted and visible (a faithful default — nothing is
1267
+ * destroyed, matching KeepAlive's core guarantee).
1268
+ *
1269
+ * @example
1270
+ * import { KeepAlive, ref } from "@pyreon/vue-compat"
1271
+ *
1272
+ * function App() {
1273
+ * const showA = ref(true)
1274
+ * return (
1275
+ * <KeepAlive active={() => showA.value}>
1276
+ * <ExpensiveTab />
1277
+ * </KeepAlive>
1278
+ * )
1279
+ * }
1280
+ */
1281
+ export function KeepAlive(props: {
1282
+ active?: () => boolean
1283
+ /** Accepted for Vue compatibility — ignored (no per-instance cache). */
1284
+ include?: string | RegExp | (string | RegExp)[]
1285
+ /** Accepted for Vue compatibility — ignored (no per-instance cache). */
1286
+ exclude?: string | RegExp | (string | RegExp)[]
1287
+ /** Accepted for Vue compatibility — ignored (no per-instance cache). */
1288
+ max?: number
1289
+ children?: VNodeChild
1290
+ }): VNodeChild {
1291
+ return PyreonKeepAlive({
1292
+ ...(props.active !== undefined ? { active: props.active } : {}),
1293
+ children: props.children ?? null,
1294
+ })
1295
+ }
1296
+
1297
+ // ─── Transition / TransitionGroup ────────────────────────────────────────────
1298
+
1299
+ /**
1300
+ * Transition — adds CSS enter/leave animation classes to a single child,
1301
+ * controlled by a reactive `show` accessor.
1302
+ *
1303
+ * Wraps `@pyreon/runtime-dom`'s Transition. Vue's class-name conventions
1304
+ * (`enter-from-class`, `enter-active-class`, …) are mapped onto Pyreon's
1305
+ * (`enterFrom`, `enterActive`, …), and Vue's `@before-enter` / `@after-enter`
1306
+ * style hooks are mapped onto Pyreon's `onBeforeEnter` / `onAfterEnter`.
1307
+ *
1308
+ * LIMITATIONS vs Vue 3:
1309
+ * - Vue's `<Transition>` infers visibility from a `v-if` / `v-show` on its
1310
+ * child. Pyreon has no template directives, so you MUST pass an explicit
1311
+ * `show: () => boolean` accessor. Without it the child is shown
1312
+ * unconditionally (no enter/leave is ever triggered).
1313
+ * - `mode` ("out-in" / "in-out"), `css: false`, and JS-only hook-driven
1314
+ * transitions are NOT supported — Pyreon's Transition is CSS-class based.
1315
+ * The props are accepted for typechecking but ignored.
1316
+ * - The Vue `name` convention (`name="fade"` → `fade-enter-from` …) is
1317
+ * preserved 1:1 (Pyreon uses the identical class-name scheme).
1318
+ *
1319
+ * @example
1320
+ * import { Transition, ref } from "@pyreon/vue-compat"
1321
+ *
1322
+ * function App() {
1323
+ * const visible = ref(false)
1324
+ * return (
1325
+ * <Transition name="fade" show={() => visible.value}>
1326
+ * <div class="modal">Hello</div>
1327
+ * </Transition>
1328
+ * )
1329
+ * }
1330
+ * // CSS:
1331
+ * // .fade-enter-from, .fade-leave-to { opacity: 0; }
1332
+ * // .fade-enter-active, .fade-leave-active { transition: opacity 300ms; }
1333
+ */
1334
+ export function Transition(props: {
1335
+ name?: string
1336
+ show?: () => boolean
1337
+ appear?: boolean
1338
+ /** Vue class-name prop — mapped to Pyreon's `enterFrom`. */
1339
+ enterFromClass?: string
1340
+ /** Vue class-name prop — mapped to Pyreon's `enterActive`. */
1341
+ enterActiveClass?: string
1342
+ /** Vue class-name prop — mapped to Pyreon's `enterTo`. */
1343
+ enterToClass?: string
1344
+ /** Vue class-name prop — mapped to Pyreon's `leaveFrom`. */
1345
+ leaveFromClass?: string
1346
+ /** Vue class-name prop — mapped to Pyreon's `leaveActive`. */
1347
+ leaveActiveClass?: string
1348
+ /** Vue class-name prop — mapped to Pyreon's `leaveTo`. */
1349
+ leaveToClass?: string
1350
+ /** Accepted for Vue compatibility — ignored (CSS-class transitions only). */
1351
+ mode?: 'in-out' | 'out-in' | 'default'
1352
+ /** Accepted for Vue compatibility — ignored (CSS-class transitions only). */
1353
+ css?: boolean
1354
+ onBeforeEnter?: (el: HTMLElement) => void
1355
+ onAfterEnter?: (el: HTMLElement) => void
1356
+ onBeforeLeave?: (el: HTMLElement) => void
1357
+ onAfterLeave?: (el: HTMLElement) => void
1358
+ children?: VNodeChild
1359
+ }): VNodeChild {
1360
+ return PyreonTransition({
1361
+ show: props.show ?? (() => true),
1362
+ ...(props.name !== undefined ? { name: props.name } : {}),
1363
+ ...(props.appear !== undefined ? { appear: props.appear } : {}),
1364
+ ...(props.enterFromClass !== undefined ? { enterFrom: props.enterFromClass } : {}),
1365
+ ...(props.enterActiveClass !== undefined ? { enterActive: props.enterActiveClass } : {}),
1366
+ ...(props.enterToClass !== undefined ? { enterTo: props.enterToClass } : {}),
1367
+ ...(props.leaveFromClass !== undefined ? { leaveFrom: props.leaveFromClass } : {}),
1368
+ ...(props.leaveActiveClass !== undefined ? { leaveActive: props.leaveActiveClass } : {}),
1369
+ ...(props.leaveToClass !== undefined ? { leaveTo: props.leaveToClass } : {}),
1370
+ ...(props.onBeforeEnter !== undefined ? { onBeforeEnter: props.onBeforeEnter } : {}),
1371
+ ...(props.onAfterEnter !== undefined ? { onAfterEnter: props.onAfterEnter } : {}),
1372
+ ...(props.onBeforeLeave !== undefined ? { onBeforeLeave: props.onBeforeLeave } : {}),
1373
+ ...(props.onAfterLeave !== undefined ? { onAfterLeave: props.onAfterLeave } : {}),
1374
+ children: props.children ?? null,
1375
+ })
1376
+ }
1377
+
1378
+ /**
1379
+ * TransitionGroup — animates a keyed reactive list with CSS enter/leave plus
1380
+ * FLIP move animations.
1381
+ *
1382
+ * Wraps `@pyreon/runtime-dom`'s TransitionGroup. Vue's class-name props are
1383
+ * mapped onto Pyreon's, same as {@link Transition}.
1384
+ *
1385
+ * LIMITATIONS vs Vue 3:
1386
+ * - Vue's `<TransitionGroup>` renders its children via slots and reads keys
1387
+ * from the child VNode `key`. Pyreon's API is explicit: pass `items`
1388
+ * (a reactive accessor), `keyFn` (stable key extractor), and `render`
1389
+ * (returns one DOM-element VNode per item). This is the faithful Pyreon
1390
+ * shape — the animation behavior (enter/leave/FLIP-move) is identical.
1391
+ * - `mode` and `css: false` are NOT supported (CSS-class transitions only).
1392
+ *
1393
+ * @example
1394
+ * import { TransitionGroup, ref } from "@pyreon/vue-compat"
1395
+ *
1396
+ * function App() {
1397
+ * const items = ref([{ id: 1 }, { id: 2 }])
1398
+ * return (
1399
+ * <TransitionGroup
1400
+ * tag="ul"
1401
+ * name="list"
1402
+ * items={() => items.value}
1403
+ * keyFn={(it) => it.id}
1404
+ * render={(it) => <li class="item">{it.id}</li>}
1405
+ * />
1406
+ * )
1407
+ * }
1408
+ */
1409
+ export function TransitionGroup<T = unknown>(props: {
1410
+ tag?: string
1411
+ name?: string
1412
+ appear?: boolean
1413
+ enterFromClass?: string
1414
+ enterActiveClass?: string
1415
+ enterToClass?: string
1416
+ leaveFromClass?: string
1417
+ leaveActiveClass?: string
1418
+ leaveToClass?: string
1419
+ /** Vue class-name prop — mapped to Pyreon's `moveClass`. */
1420
+ moveClass?: string
1421
+ items: () => T[]
1422
+ keyFn: (item: T, index: number) => string | number
1423
+ render: (item: T, index: number) => ReturnType<typeof pyreonH>
1424
+ onBeforeEnter?: (el: HTMLElement) => void
1425
+ onAfterEnter?: (el: HTMLElement) => void
1426
+ onBeforeLeave?: (el: HTMLElement) => void
1427
+ onAfterLeave?: (el: HTMLElement) => void
1428
+ }): VNodeChild {
1429
+ return PyreonTransitionGroup<T>({
1430
+ items: props.items,
1431
+ keyFn: props.keyFn,
1432
+ render: props.render,
1433
+ ...(props.tag !== undefined ? { tag: props.tag } : {}),
1434
+ ...(props.name !== undefined ? { name: props.name } : {}),
1435
+ ...(props.appear !== undefined ? { appear: props.appear } : {}),
1436
+ ...(props.enterFromClass !== undefined ? { enterFrom: props.enterFromClass } : {}),
1437
+ ...(props.enterActiveClass !== undefined ? { enterActive: props.enterActiveClass } : {}),
1438
+ ...(props.enterToClass !== undefined ? { enterTo: props.enterToClass } : {}),
1439
+ ...(props.leaveFromClass !== undefined ? { leaveFrom: props.leaveFromClass } : {}),
1440
+ ...(props.leaveActiveClass !== undefined ? { leaveActive: props.leaveActiveClass } : {}),
1441
+ ...(props.leaveToClass !== undefined ? { leaveTo: props.leaveToClass } : {}),
1442
+ ...(props.moveClass !== undefined ? { moveClass: props.moveClass } : {}),
1443
+ ...(props.onBeforeEnter !== undefined ? { onBeforeEnter: props.onBeforeEnter } : {}),
1444
+ ...(props.onAfterEnter !== undefined ? { onAfterEnter: props.onAfterEnter } : {}),
1445
+ ...(props.onBeforeLeave !== undefined ? { onBeforeLeave: props.onBeforeLeave } : {}),
1446
+ ...(props.onAfterLeave !== undefined ? { onAfterLeave: props.onAfterLeave } : {}),
1447
+ })
1448
+ }
1449
+
1450
+ // ─── Suspense ────────────────────────────────────────────────────────────────
1451
+
1452
+ /**
1453
+ * Suspense — shows `fallback` content while an async (lazy) child is loading.
1454
+ *
1455
+ * Re-exports `@pyreon/core`'s Suspense. Vue 3's `<Suspense>` uses named
1456
+ * `#default` / `#fallback` slots; this maps the `fallback` slot to Pyreon
1457
+ * Suspense's `fallback` prop and the default slot to `children`.
1458
+ *
1459
+ * LIMITATIONS vs Vue 3:
1460
+ * - Vue resolves `<Suspense>` against any `async setup()` in the subtree and
1461
+ * supports `@resolve` / `@pending` / `@fallback` events plus the `timeout`
1462
+ * prop. Pyreon's Suspense resolves against components carrying a
1463
+ * `__loading` accessor (e.g. {@link defineAsyncComponent} output) and does
1464
+ * not emit those events. The events / `timeout` prop are accepted for
1465
+ * typechecking but ignored.
1466
+ *
1467
+ * @example
1468
+ * import { Suspense, defineAsyncComponent } from "@pyreon/vue-compat"
1469
+ *
1470
+ * const AsyncPage = defineAsyncComponent(() => import("./Page"))
1471
+ *
1472
+ * function App() {
1473
+ * return (
1474
+ * <Suspense fallback={<div>Loading…</div>}>
1475
+ * <AsyncPage />
1476
+ * </Suspense>
1477
+ * )
1478
+ * }
1220
1479
  */
1221
- export function KeepAlive(props: { children?: VNodeChild }): VNodeChild {
1222
- return props.children ?? null
1480
+ export function Suspense(props: {
1481
+ fallback?: VNodeChild
1482
+ /** Accepted for Vue compatibility — ignored (no timeout phase). */
1483
+ timeout?: number
1484
+ children?: VNodeChild
1485
+ }): VNodeChild {
1486
+ return PyreonSuspense({
1487
+ fallback: props.fallback ?? null,
1488
+ children: props.children ?? null,
1489
+ })
1490
+ }
1491
+
1492
+ // ─── getCurrentInstance / useSlots / useAttrs ────────────────────────────────
1493
+
1494
+ /**
1495
+ * Vue-compatible component-instance handle.
1496
+ *
1497
+ * Backed by the compat hook-context. Only the fields commonly read by
1498
+ * composable libraries are populated. See {@link getCurrentInstance}.
1499
+ */
1500
+ export interface ComponentInternalInstance {
1501
+ /** Monotonic per-instance id. */
1502
+ uid: number
1503
+ /**
1504
+ * Vue's `proxy` — the component public instance. In this shim it is an
1505
+ * empty object (Pyreon components are plain functions; there is no
1506
+ * `this`-bound options instance). Present so `inst.proxy` access doesn't
1507
+ * throw; do not rely on reading reactive state off it.
1508
+ */
1509
+ proxy: Record<string, unknown>
1510
+ /** Slots derived from the current component's children. */
1511
+ slots: Record<string, (() => VNodeChild) | undefined>
1512
+ /** Fallthrough attrs (declared props excluded — mirrors `useAttrs()`). */
1513
+ attrs: Record<string, unknown>
1514
+ /**
1515
+ * Emits an event by invoking the matching `on{Event}` prop handler —
1516
+ * same behavior as the `emit` on `defineComponent`'s setup context.
1517
+ * Libraries that call `instance.emit(...)` (vee-validate, etc.) work.
1518
+ */
1519
+ emit: (event: string, ...args: unknown[]) => void
1520
+ /** `true` — present for libraries that branch on `isMounted`-like flags. */
1521
+ isMounted: boolean
1522
+ /** @internal — the underlying compat render context. */
1523
+ _ctx: ReturnType<typeof getCurrentCtx>
1524
+ }
1525
+
1526
+ let _instanceUid = 0
1527
+
1528
+ /**
1529
+ * Returns a handle to the current component instance, or `null` if called
1530
+ * outside a component setup.
1531
+ *
1532
+ * Vue 3's `getCurrentInstance()` is an internal API many composable libraries
1533
+ * (vee-validate, vue-i18n, pinia plugins, …) read for `uid`, `proxy`, `slots`,
1534
+ * `attrs`. This shim returns a minimal stable object with those fields so such
1535
+ * libraries don't crash.
1536
+ *
1537
+ * LIMITATIONS vs Vue 3:
1538
+ * - `proxy` is an empty object — Pyreon components are plain functions with
1539
+ * no `this`-bound Options instance. Code that reads reactive state off
1540
+ * `instance.proxy.$data` / `.$props` will not work; use `props` directly.
1541
+ * - `instance.emit(event, ...args)` IS provided (invokes the matching
1542
+ * `on{Event}` prop handler). `instance.attrs` is the fallthrough split
1543
+ * (declared props excluded) when the component used `defineComponent({
1544
+ * props })`; otherwise it is the full props object.
1545
+ * - `appContext`, `parent`, `vnode`, `expose`, render internals are NOT
1546
+ * provided. Libraries that walk the parent chain are not supported.
1547
+ * - The same `uid` is stable across re-renders of the same instance
1548
+ * (hook-indexed), matching Vue's per-instance-id guarantee.
1549
+ *
1550
+ * @example
1551
+ * import { getCurrentInstance } from "@pyreon/vue-compat"
1552
+ *
1553
+ * function useUid() {
1554
+ * const inst = getCurrentInstance()
1555
+ * return inst ? inst.uid : -1
1556
+ * }
1557
+ */
1558
+ export function getCurrentInstance(): ComponentInternalInstance | null {
1559
+ const ctx = getCurrentCtx()
1560
+ if (!ctx) return null
1561
+
1562
+ const idx = getHookIndex()
1563
+ if (idx < ctx.hooks.length) {
1564
+ return ctx.hooks[idx] as ComponentInternalInstance
1565
+ }
1566
+
1567
+ const props = ctx._props ?? {}
1568
+ const children = (props as Record<string, unknown>).children as VNodeChild | undefined
1569
+ const instance: ComponentInternalInstance = {
1570
+ uid: _instanceUid++,
1571
+ proxy: {},
1572
+ slots: {
1573
+ default: children !== undefined ? () => children : undefined,
1574
+ },
1575
+ attrs: splitVueAttrs(props, ctx._declaredProps),
1576
+ emit: (event: string, ...args: unknown[]) => {
1577
+ const handlerKey = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`
1578
+ const handler = (props as Record<string, unknown>)[handlerKey]
1579
+ if (typeof handler === 'function') (handler as (...a: unknown[]) => void)(...args)
1580
+ },
1581
+ isMounted: true,
1582
+ _ctx: ctx,
1583
+ }
1584
+ ctx.hooks[idx] = instance
1585
+ return instance
1586
+ }
1587
+
1588
+ /**
1589
+ * Returns the current component's slots — a map of slot-name → render
1590
+ * function. Only the `default` slot is populated (derived from `children`),
1591
+ * matching how `@pyreon/vue-compat` models slots elsewhere
1592
+ * ({@link defineComponent}'s setup context).
1593
+ *
1594
+ * LIMITATIONS vs Vue 3:
1595
+ * - Vue supports arbitrary named + scoped slots resolved from the parent
1596
+ * template. Pyreon passes a single `children` payload, so only
1597
+ * `slots.default` is available. Named/scoped slots are not modeled.
1598
+ * - Returns an empty object (no `default`) when there are no children or
1599
+ * when called outside a component.
1600
+ *
1601
+ * @example
1602
+ * import { useSlots } from "@pyreon/vue-compat"
1603
+ *
1604
+ * function Wrapper() {
1605
+ * const slots = useSlots()
1606
+ * return <div class="box">{slots.default?.()}</div>
1607
+ * }
1608
+ */
1609
+ export function useSlots(): Record<string, (() => VNodeChild) | undefined> {
1610
+ const ctx = getCurrentCtx()
1611
+ if (!ctx) return {}
1612
+ const props = ctx._props ?? {}
1613
+ const children = (props as Record<string, unknown>).children as VNodeChild | undefined
1614
+ if (children === undefined) return {}
1615
+ return { default: () => children }
1616
+ }
1617
+
1618
+ /**
1619
+ * Returns the current component's non-prop attributes.
1620
+ *
1621
+ * In Vue, `useAttrs()` is the fallthrough attributes NOT declared in `props`.
1622
+ * `@pyreon/vue-compat` does not do declared-prop separation (components are
1623
+ * plain functions receiving one `props` object), so this returns the full
1624
+ * props object — every consumer-supplied attribute is present.
1625
+ *
1626
+ * LIMITATIONS vs Vue 3:
1627
+ * - No declared-vs-fallthrough split: `useAttrs()` here === the full props
1628
+ * object (including any that Vue would have consumed as declared props).
1629
+ * Read the specific keys you need; don't assume the result excludes
1630
+ * declared props.
1631
+ * - Returns an empty object when called outside a component.
1632
+ *
1633
+ * @example
1634
+ * import { useAttrs } from "@pyreon/vue-compat"
1635
+ *
1636
+ * function Passthrough() {
1637
+ * const attrs = useAttrs()
1638
+ * return <input {...attrs} />
1639
+ * }
1640
+ */
1641
+ export function useAttrs(): Record<string, unknown> {
1642
+ const ctx = getCurrentCtx()
1643
+ if (!ctx) return {}
1644
+ // Vue's `attrs` = fallthrough props (declared props excluded). The split is
1645
+ // known when the component used `defineComponent({ props })`; otherwise the
1646
+ // full props object is returned (honest — the split is unknowable).
1647
+ return splitVueAttrs(ctx._props ?? {}, ctx._declaredProps)
1223
1648
  }
1224
1649
 
1225
1650
  // ─── watchPostEffect / watchSyncEffect ───────────────────────────────────────
@@ -33,6 +33,19 @@ export interface RenderContext {
33
33
  unmounted: boolean
34
34
  /** Callbacks to run on unmount (lifecycle + effect cleanups) */
35
35
  unmountCallbacks: (() => void)[]
36
+ /**
37
+ * The props object the wrapped component was invoked with. Read by
38
+ * `getCurrentInstance()` / `useSlots()` / `useAttrs()` to derive slots
39
+ * + attrs. Set by the wrapper before each render.
40
+ */
41
+ _props?: Record<string, unknown>
42
+ /**
43
+ * Names of props declared via `defineComponent({ props })`. Set by
44
+ * `defineComponent` so `useAttrs()` / `getCurrentInstance().attrs` can
45
+ * compute the Vue declared-vs-fallthrough split. Undefined when the
46
+ * component didn't declare props (then attrs = the full props object).
47
+ */
48
+ _declaredProps?: string[]
36
49
  }
37
50
 
38
51
  export interface EffectEntry {
@@ -106,6 +119,7 @@ function wrapCompatComponent(vueComponent: Function): ComponentFn {
106
119
  pendingLayoutEffects: [],
107
120
  unmounted: false,
108
121
  unmountCallbacks: [],
122
+ _props: props as Record<string, unknown>,
109
123
  }
110
124
 
111
125
  const version = signal(0)