Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions RESULT-optimization-compact-vue-private-reactivity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Compact Vue private reactivity

Baseline: `main` at `697ebb6ddbd433d052b6b4707938a5c595865d58`.

## Principle

Do not retain a reactive wrapper when its value or identity never escapes, and
do not allocate a computed tuple when the render that consumes it is already
driven by the same single reactive source.

This candidate applies that principle to private Vue Router implementation
details:

- the IntersectionObserver helper's callers discard its returned ref, so each
watch execution can keep its observer in the cleanup closure that owns it;
- `MatchInner` can derive its match and remount key from one `activeMatch`
snapshot in the render that consumes both, removing a computed and tuple; and
- the outer match subscription uses vue-store's exact default identity selector
while retaining the explicit `Object.is` equality function. This last source
simplification is kept only as part of the measured final composition, not as
a standalone optimization rule.

No supported public API or emitted public declaration changes.

## Bundle result

`vue-router.full`:

| Metric | Before | After | Change |
| -------------- | --------: | --------: | -----: |
| raw | 165,676 B | 165,546 B | -130 B |
| initial raw | 165,552 B | 165,422 B | -130 B |
| gzip | 59,444 B | 59,405 B | -39 B |
| initial gzip | 59,312 B | 59,272 B | -40 B |
| Brotli | 53,331 B | 53,309 B | -22 B |
| initial Brotli | 53,218 B | 53,192 B | -26 B |

Only the four Vue scenarios change. Every affected scenario contains 130 fewer
raw and initial bytes and improves primary gzip and initial gzip size:

| Scenario | Raw | Gzip | Initial gzip | Brotli | Initial Brotli |
| ------------------ | -----: | ----: | -----------: | -----: | -------------: |
| vue-router.minimal | -130 B | -38 B | -35 B | -20 B | -41 B |
| vue-router.full | -130 B | -39 B | -40 B | -22 B | -26 B |
| vue-start.minimal | -130 B | -43 B | -45 B | -50 B | -53 B |
| vue-start.full | -130 B | -32 B | -33 B | +25 B | +7 B |

The other thirteen scenarios are byte-identical in raw, initial raw, gzip,
initial gzip, Brotli, initial Brotli, and JavaScript file count. The only
secondary compression regression is 25 B Brotli in `vue-start.full`, which
still contains 130 fewer raw bytes and improves by 32 B gzip.

Fresh paired full-matrix artifacts:

- exact base, SHA-256
`a33ee6b69b6dec26bd3548b642cf5a6446c58743adb395d32623d57e498b02c6`:
`/private/tmp/vue-blocker-final-control-full.json`
- final candidate at `205d3bac73af5c41f5bd45c56fb38253c5620507`,
SHA-256
`2ea24fb0d11d023b584a3b169c19f1c54eb07c499f189e70cd6ced0b882c88af`:
`/private/tmp/vue-private-reactivity-final-full.json`

## Hunk attribution

All production stages were measured from the same exact base across the full
17-scenario matrix. In `vue-router.full`:

| Production stage | Raw | Gzip | Initial gzip | Brotli |
| ------------------------------- | -----: | ----: | -----------: | -----: |
| Observer local only | -43 B | -20 B | -20 B | +57 B |
| Match derivation only | -89 B | -20 B | -20 B | +30 B |
| Default selector only | +2 B | +1 B | 0 B | +23 B |
| Observer + Match derivation | -132 B | -39 B | -39 B | +78 B |
| Final composition with selector | -130 B | -39 B | -40 B | -22 B |

Compression is nonlinear. The default-selector source simplification is not an
isolated byte win, so its composition was compared explicitly across every
retained scenario before keeping it. Across the four Vue scenarios, adding it
to Observer + Match changes aggregate gzip from -151 B to -152 B, aggregate
initial gzip from -150 B to -153 B, and aggregate Brotli from +47 B to -67 B.
It costs 8 aggregate raw bytes and one gzip byte in `vue-start.full`, but repairs
the larger Brotli regressions in `vue-router.full` and `vue-start.minimal` and
produces the better overall compressed profile.

Attribution artifacts:

- observer only: `/private/tmp/vue-attrib-observer-only-full.json`
- Match derivation only:
`/private/tmp/vue-attrib-match-derivation-only-full.json`
- default selector only:
`/private/tmp/vue-attrib-default-selector-only-full.json`
- observer plus Match derivation:
`/private/tmp/vue-attrib-observer-match-full.json`

## Composition gate

The first candidate also removed a Vue lazy-component loading ref and the same
unused observer return state in Solid. Both improved gzip in isolation, but the
full matrix exposed secondary regressions. Targeted subset builds on
`vue-start.minimal` selected the balanced final group:

| Subset | Raw | Gzip | Initial gzip | Brotli |
| --------------------------- | -----: | ----: | -----------: | -----: |
| Lazy state + Match | -157 B | -50 B | -50 B | +117 B |
| Vue observer + Match | -130 B | -43 B | -45 B | -50 B |
| Lazy state + Vue observer | -113 B | -47 B | -49 B | +63 B |
| Initial all-Vue composition | -200 B | -68 B | -70 B | +133 B |

The lazy state hunk was therefore dropped. The Solid observer hunk was also
dropped: it saved 9–14 B gzip in Solid scenarios but produced a 97 B Brotli
increase in `solid-start.minimal`. Neither rejected hunk, nor its tests, appears
in the final branch.

## Runtime and compatibility

- Vue's observer `watchEffect` still depends only on the element and disabled
state. Each execution creates and observes one instance; its cleanup closes
over and disconnects that exact instance before rerun or unmount.
- The removed observer ref was written but never read by either shipped caller.
The helper is not exported by the package entrypoint or export map.
- `MatchInner` reads the same latest match, passes the same route ID, loader
dependencies, params, and search to `remountDeps`, and stringifies the same
truthy result before the same status handling.
- `MatchInner` has no changing props, slots, or other reactive inputs, so normal
rerenders remain active-match-driven. Any reactive values read inside a user
`remountDeps` callback remain tracked by the render effect.
- The installed vue-store version uses identity as its default selector;
`Object.is` remains the explicit equality function.
- No exports, component props, route options, module entrypoints, or top-level
effects change.

The candidate removes one Vue ref and its writes, one computed and tuple
allocation, and one callsite lambda without adding loops, scans, listeners, or
DOM work. A synthetic benchmark would not resolve a semantic uncertainty and
was not warranted.

## Validation

- Focused final candidate: 2 files, 260 passed, no type errors.
- The same final tests on exact base: 2 files, 260 passed, no type errors.
- Vue Router full unit suite: 54 files, 816 passed and 1 existing skip; no type
errors.
- Vue Router type suite: 17 files, 138 passed.
- Vue Router ESLint: 0 errors; 79 pre-existing warnings.
- Vue remount-dependencies e2e: 2 passed.
- Full 17-scenario bundle-size matrix: passed.
- Five independent exact-HEAD reviews approved Vue lifecycle/runtime semantics,
public API and tree-shaking safety, test adequacy and parity, maintainability,
bundle attribution, compression tradeoffs, and publishability.
- Formatting and `git diff --check`: passed.

Focused tests cover observer replacement, disabled cleanup, re-enabling,
unmount cleanup, latest remount inputs, stable-key instance reuse, and changed-key
remounting. The exact-base parity worktree is
`/private/tmp/router-vue-private-final-parity.38ntQk/worktree`.
25 changes: 5 additions & 20 deletions packages/vue-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const Match = Vue.defineComponent({

const activeMatch = useStore(
router.stores.getMatchStore(routeId),
(value) => value,
undefined,
{ equal: Object.is },
)
// Provide routeId context (stable string) for children.
Expand Down Expand Up @@ -144,20 +144,19 @@ export const MatchInner = Vue.defineComponent({
const routeId = Vue.inject(routeIdContext)!
const activeMatch = useStore(router.stores.getMatchStore(routeId))

// Combined selector for match state AND remount key
// This ensures both are computed in the same selector call with consistent data
const combinedState = Vue.computed(() => {
return (): VNode | null => {
const match = activeMatch.value
if (!match) {
// Route no longer exists - truly navigating away
return null
}

const matchRouteId = match.routeId as string
const route = router.routesById[matchRouteId]!

// Compute remount key
const remountFn =
(router.routesById[matchRouteId] as AnyRoute).options.remountDeps ??
(route as AnyRoute).options.remountDeps ??
router.options.defaultRemountDeps

const remountDeps = remountFn
Expand All @@ -168,21 +167,7 @@ export const MatchInner = Vue.defineComponent({
search: match._strictSearch,
})
: undefined

return [
match,
remountDeps ? JSON.stringify(remountDeps) : undefined,
] as const
})

return (): VNode | null => {
// If match doesn't exist, return null (component is being unmounted or not ready)
const state = combinedState.value
if (!state) {
return null
}
const [match, remountKey] = state
const route = router.routesById[match.routeId]!
const remountKey = remountDeps ? JSON.stringify(remountDeps) : undefined

// Handle different match statuses
if (match.status === 'notFound') {
Expand Down
8 changes: 1 addition & 7 deletions packages/vue-router/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export const usePrevious = (fn: () => boolean) => {
* @param intersectionObserverOptions - The options to pass to the IntersectionObserver
* @param options - The options to pass to the hook
* @param callback - The callback to call when the intersection changes
* @returns The IntersectionObserver instance
* @example
* ```tsx
* const MyComponent = () => {
Expand All @@ -53,10 +52,9 @@ export function useIntersectionObserver<T extends Element>(
callback: (entry: IntersectionObserverEntry | undefined) => void,
intersectionObserverOptions: IntersectionObserverInit = {},
options: { disabled?: boolean | (() => boolean) } = {},
): Vue.Ref<IntersectionObserver | null> {
): void {
const isIntersectionObserverAvailable =
typeof IntersectionObserver === 'function'
const observerRef = Vue.ref<IntersectionObserver | null>(null)

// Use watchEffect with cleanup to properly manage the observer lifecycle
Vue.watchEffect((onCleanup) => {
Expand All @@ -74,16 +72,12 @@ export function useIntersectionObserver<T extends Element>(
callback(entry)
}, intersectionObserverOptions)

observerRef.value = observer
observer.observe(r)

onCleanup(() => {
observer.disconnect()
observerRef.value = null
})
})

return observerRef
}

export function splitProps<T extends Record<string, any>>(
Expand Down
43 changes: 43 additions & 0 deletions packages/vue-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
useRouteContext,
useSearch,
} from '../src'
import { useIntersectionObserver } from '../src/utils'
import {
getIntersectionObserverMock,
getSearchParamsFromURI,
Expand Down Expand Up @@ -5053,6 +5054,48 @@ describe('Link', () => {
expect(ioDisconnectMock).not.toHaveBeenCalled() // it should not disconnect again
})

test('the viewport observer follows ref, disabled, and unmount transitions', async () => {
ioObserveMock.mockClear()
ioDisconnectMock.mockClear()
const disabled = Vue.ref(false)
const element = Vue.shallowRef<Element | null>(
document.createElement('div'),
)
const ObserverComponent = Vue.defineComponent({
setup() {
useIntersectionObserver(
element,
() => {},
{},
{
disabled: () => disabled.value,
},
)
return () => null
},
})

const { unmount } = render(ObserverComponent)

await waitFor(() => expect(ioObserveMock).toHaveBeenCalledTimes(1))
expect(ioDisconnectMock).not.toHaveBeenCalled()

element.value = document.createElement('span')
await waitFor(() => expect(ioObserveMock).toHaveBeenCalledTimes(2))
expect(ioDisconnectMock).toHaveBeenCalledTimes(1)

disabled.value = true
await waitFor(() => expect(ioDisconnectMock).toHaveBeenCalledTimes(2))
expect(ioObserveMock).toHaveBeenCalledTimes(2)

disabled.value = false
await waitFor(() => expect(ioObserveMock).toHaveBeenCalledTimes(3))
expect(ioDisconnectMock).toHaveBeenCalledTimes(2)

unmount()
expect(ioDisconnectMock).toHaveBeenCalledTimes(3)
})

test("Router.preload='render', should trigger the route loader on render", async () => {
const mock = vi.fn()

Expand Down
73 changes: 73 additions & 0 deletions packages/vue-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Vue from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
cleanup,
Expand Down Expand Up @@ -923,6 +924,78 @@ describe('router emits events during rendering', () => {
})
})

describe('router rendering stability', () => {
it('uses remount deps from the latest match as the component key', async () => {
const mounts = vi.fn()
const remountDeps = vi.fn((options: { search: { version: string } }) => ({
version: options.search.version,
}))
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const itemRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/items',
validateSearch: z.object({
version: z.string(),
ignored: z.string().optional(),
}),
remountDeps,
component: Vue.defineComponent({
setup() {
mounts()
return () => <div>Item route</div>
},
}),
})
const router = createRouter({
routeTree: rootRoute.addChildren([itemRoute]),
history: createMemoryHistory({
initialEntries: ['/items?version=one&ignored=first'],
}),
})

render(<RouterProvider router={router} />)

expect(await screen.findByText('Item route')).toBeInTheDocument()
expect(mounts).toHaveBeenCalledTimes(1)
expect(remountDeps).toHaveBeenLastCalledWith({
routeId: '/items',
loaderDeps: '',
params: {},
search: { version: 'one', ignored: 'first' },
})

await router.navigate({
to: '/items',
search: { version: 'one', ignored: 'second' },
})

await waitFor(() => {
expect(remountDeps).toHaveBeenLastCalledWith({
routeId: '/items',
loaderDeps: '',
params: {},
search: { version: 'one', ignored: 'second' },
})
})
expect(mounts).toHaveBeenCalledTimes(1)

await router.navigate({
to: '/items',
search: { version: 'two', ignored: 'second' },
})

await waitFor(() => expect(mounts).toHaveBeenCalledTimes(2))
expect(remountDeps).toHaveBeenLastCalledWith({
routeId: '/items',
loaderDeps: '',
params: {},
search: { version: 'two', ignored: 'second' },
})
})
})

describe('router matches URLs to route definitions', () => {
it('solo splat route matches index route', async () => {
const { router } = createTestRouter({
Expand Down
Loading