Skip to content
Open
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
3 changes: 3 additions & 0 deletions e2e/app/hash-nav/+page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ export default () => (
Deep Dive Target
</h3>
<p data-testid="hash-nav-target-copy">The target should be scrolled into view.</p>
<p>
<Link href="/counter">Open counter after deep scroll</Link>
</p>
</section>
)
29 changes: 29 additions & 0 deletions e2e/test/example.dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,22 @@ test.describe('example app in dev mode', () => {
.toBe(true)
})

test('resets scroll for client-side Link navigation without a hash target', async ({ page }) => {
await page.goto('/hash-nav')
await waitForResumedRoute(page)

await page.getByRole('link', { name: 'Jump to deep dive' }).click()

await expect(page).toHaveURL(/\/hash-nav#deep-dive$/)
await expect.poll(async () => await page.evaluate(() => window.scrollY)).toBeGreaterThan(1000)

await page.getByRole('link', { name: 'Open counter after deep scroll' }).click()

await expect(page).toHaveURL(/\/counter$/)
await expect.poll(async () => await page.evaluate(() => window.scrollY)).toBe(0)
await expect(page.getByText('Counter page')).toBeVisible()
})

test('updates shared layout-owned location state on Link navigation', async ({ page }) => {
await page.goto('/layout-location/overview')

Expand Down Expand Up @@ -584,6 +600,19 @@ test.describe('example app in dev mode', () => {
)
})

test('runs onMount for directly loaded routes after refs are connected', async ({ page }) => {
await page.goto('/mount-connected-target')
await waitForResumedRoute(page)

await expect(page.getByTestId('mount-connected-state')).toHaveText('connected')
await expect(page.getByTestId('mount-connected-canvas')).toHaveJSProperty('width', 321)
await expect(page.getByTestId('mount-connected-canvas')).toHaveJSProperty('height', 123)
await expect(page.getByTestId('mount-connected-canvas')).toHaveAttribute(
'data-mounted-canvas',
'true',
)
})

test('keeps motion section titles when sidebar links patch a shared layout shell', async ({
page,
}) => {
Expand Down
15 changes: 15 additions & 0 deletions packages/eclipsa/core/resume-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,19 @@ describe('resume loader', () => {
),
).toBe(true)
})

it('requires full resume when mount callbacks are serialized', () => {
expect(
needsFullResumeOnStart(
createPayload({
components: {
c0: {
mountCount: 1,
} as any,
},
}),
{ client: null },
),
).toBe(true)
})
})
4 changes: 3 additions & 1 deletion packages/eclipsa/core/resume-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,5 +304,7 @@ export const needsFullResumeOnStart = (
) {
return true
}
return Object.values(payload.components ?? {}).some((component) => !!component.external)
return Object.values(payload.components ?? {}).some(
(component) => !!component.external || (component.mountCount ?? 0) > 0,
)
}
54 changes: 48 additions & 6 deletions packages/eclipsa/core/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ const createInactiveComponentState = (
externalInstance: undefined,
externalMeta: null,
id,
mountCount: 0,
mountCleanupSlots: null,
mayChangeNodeCount: false,
optimizedRoot: false,
Expand Down Expand Up @@ -624,6 +625,7 @@ const materializeComponentStateFields = (component: ComponentState) => {
component.active ??= false
component.childComponentIds ??= null
component.didMount ??= false
component.mountCount ??= 0
component.mountCleanupSlots ??= null
component.mayChangeNodeCount ??= false
component.props ??= null
Expand Down Expand Up @@ -3077,6 +3079,7 @@ const createFrame = (
frame.insertCursor = 0
frame.keyedRangeCursor = 0
frame.keyedRangeScopeStack = null
frame.mountCursor = 0
frame.mountCallbacks = null
frame.mode = mode
frame.nextEffectCursor = 0
Expand Down Expand Up @@ -3105,6 +3108,7 @@ const createFrame = (
insertCursor: 0,
keyedRangeCursor: 0,
keyedRangeScopeStack: null,
mountCursor: 0,
mountCallbacks: null,
mode,
nextEffectCursor: 0,
Expand Down Expand Up @@ -3247,6 +3251,7 @@ const resetComponentForSymbolChange = (
component.scopeId = captures.length > 0 ? registerScope(container, captures) : null
component.signalIds = EMPTY_COMPONENT_SIGNAL_IDS
component.suspensePromise = null
pruneComponentMounts(component, 0)
pruneComponentVisibles(container, component, 0)
pruneComponentWatches(container, component, 0)
}
Expand Down Expand Up @@ -3448,6 +3453,10 @@ const pruneComponentWatches = (
component.watchCount = nextCount
}

const pruneComponentMounts = (component: ComponentState, nextCount: number) => {
component.mountCount = nextCount
}

const pruneComponentVisibles = (
container: RuntimeContainer,
component: ComponentState,
Expand Down Expand Up @@ -3517,6 +3526,7 @@ const pruneRemovedComponents = (
const descendant = container.components.get(descendantId)
if (descendant) {
disposeComponentMountCleanups(descendant)
pruneComponentMounts(descendant, 0)
pruneComponentVisibles(container, descendant, 0)
pruneComponentWatches(container, descendant, 0)
descendant.childComponentIds?.clear()
Expand All @@ -3530,6 +3540,7 @@ const disposeComponentState = (container: RuntimeContainer, component: Component
clearComponentSubscriptions(container, component.id)
disposeCleanupSlot(component.renderEffectCleanupSlot)
disposeComponentMountCleanups(component)
pruneComponentMounts(component, 0)
pruneComponentVisibles(container, component, 0)
pruneComponentWatches(container, component, 0)
for (const signalId of component.signalIds) {
Expand Down Expand Up @@ -5886,6 +5897,7 @@ const renderStringNode = (inputElementLike: JSX.Element | JSX.Element[]): string
const renderProps = createRenderProps(componentId, meta, resolved.props)

const body = pushFrame(frame, () => renderStringNode(componentFn(renderProps)))
pruneComponentMounts(component, frame.mountCursor)
pruneComponentVisibles(container, component, frame.visibleCursor)
pruneComponentWatches(container, component, frame.watchCursor)
const rendered = `${createComponentBoundaryHtmlComment(componentId, 'start')}${renderFrameScopedStylesToString(frame)}${body}${createComponentBoundaryHtmlComment(componentId, 'end')}`
Expand Down Expand Up @@ -6168,6 +6180,7 @@ const renderComponentToNodes = (
throw error
}
disposeCleanupSlot(speculativeEffectCleanupSlot)
pruneComponentMounts(component, frame.mountCursor)
pruneComponentVisibles(container, component, frame.visibleCursor)
pruneComponentWatches(container, component, frame.watchCursor)
const preservedDescendants =
Expand Down Expand Up @@ -6919,6 +6932,7 @@ const teardownKeyedForOwnerState = (
clearComponentSubscriptions(container, ownerComponent.id)
resetComponentRenderEffects(ownerComponent)
pruneRemovedComponents(container, ownerComponent.id, new Set())
pruneComponentMounts(ownerComponent, 0)
pruneComponentVisibles(container, ownerComponent, 0)
pruneComponentWatches(container, ownerComponent, 0)
removeNodesFromParent(currentNodes, parent)
Expand Down Expand Up @@ -8116,7 +8130,7 @@ setCompiledRuntimeEffectWrapper((fn) => {

setCompiledRuntimeMountScheduler((fn) => {
const frame = getCurrentFrame()
if (!frame || frame.component.id === ROOT_COMPONENT_ID || frame.mode !== 'client') {
if (!frame || frame.component.id === ROOT_COMPONENT_ID) {
return false
}
createOnMount(fn)
Expand All @@ -8136,6 +8150,7 @@ const resetContainerForRouteRender = (container: RuntimeContainer) => {
container.rootChildComponentIds ??= new Set()
for (const component of container.components.values()) {
disposeComponentMountCleanups(component)
pruneComponentMounts(component, 0)
pruneComponentVisibles(container, component, 0)
pruneComponentWatches(container, component, 0)
}
Expand Down Expand Up @@ -8309,6 +8324,7 @@ const renderSuspenseComponentToString = (props: SuspenseProps) => {
const body = pushFrame(frame, () =>
renderSuspenseContentToString(component.props as SuspenseProps, container, componentId),
)
pruneComponentMounts(component, frame.mountCursor)
pruneComponentVisibles(container, component, frame.visibleCursor)
pruneComponentWatches(container, component, frame.watchCursor)
return `${createComponentBoundaryHtmlComment(componentId, 'start')}${body}${createComponentBoundaryHtmlComment(componentId, 'end')}`
Expand Down Expand Up @@ -8346,6 +8362,7 @@ const renderSuspenseComponentToNodes = (
const bodyNodes = pushFrame(frame, () =>
renderSuspenseContentToNodes(component.props as SuspenseProps, container, componentId),
)
pruneComponentMounts(component, frame.mountCursor)
pruneComponentVisibles(container, component, frame.visibleCursor)
pruneComponentWatches(container, component, frame.watchCursor)
const parentVisitedDescendants = ensureFrameVisitedDescendants(parentFrame)
Expand Down Expand Up @@ -8927,8 +8944,17 @@ const commitBrowserNavigation = (doc: Document, url: URL, mode: NavigationMode)
doc.defaultView.history.pushState(null, '', url.href)
}

const scrollToUrlFragment = (doc: Document, url: URL) => {
const scrollToUrlTarget = (
doc: Document,
url: URL,
options?: {
resetScroll?: boolean
},
) => {
if (!url.hash) {
if (options?.resetScroll) {
doc.defaultView?.scrollTo(0, 0)
}
return
}

Expand Down Expand Up @@ -9224,7 +9250,9 @@ const commitRouteNavigation = (
if (options?.writeLocation !== false) {
writeRouterLocation(router, url)
}
scrollToUrlFragment(doc, url)
scrollToUrlTarget(doc, url, {
resetScroll: mode !== 'pop',
})
Comment on lines +9253 to +9255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve scroll during forced route refreshes

When a content/HMR route refresh calls refreshRouteContainer, it re-enters navigateContainer with force: true and mode: 'replace' for the current URL. For hash-less pages this new mode !== 'pop' reset makes an in-place refresh jump to the top, whereas the previous no-hash path kept the user's scroll and refreshRouteContainerForHmr still rerenders without scrolling. This only shows up on forced refreshes of the current route, but it makes dev/content updates disruptive for long pages.

Useful? React with 👍 / 👎.

}

const renderAndCommitRouteNavigation = (
Expand Down Expand Up @@ -9391,7 +9419,9 @@ const navigateContainer = async (
if (nextHref !== currentHref) {
commitBrowserNavigation(doc, url, mode)
writeRouterLocation(router, url)
scrollToUrlFragment(doc, url)
scrollToUrlTarget(doc, url, {
resetScroll: mode !== 'pop',
})
}
return
}
Expand Down Expand Up @@ -9622,6 +9652,7 @@ const activateComponent = async (container: RuntimeContainer, componentId: strin
throw error
}
disposeCleanupSlot(suspenseSpeculativeEffectCleanupSlot)
pruneComponentMounts(component, frame.mountCursor)
pruneComponentVisibles(container, component, frame.visibleCursor)
pruneComponentWatches(container, component, frame.watchCursor)
const patched =
Expand Down Expand Up @@ -9798,6 +9829,7 @@ const activateComponent = async (container: RuntimeContainer, componentId: strin
})
}
disposeCleanupSlot(speculativeEffectCleanupSlot)
pruneComponentMounts(component, frame.mountCursor)
pruneComponentVisibles(container, component, frame.visibleCursor)
pruneComponentWatches(container, component, frame.watchCursor)
const patched =
Expand Down Expand Up @@ -10264,6 +10296,7 @@ export const beginSSRContainer = <T>(
childComponentIds: new Set(),
didMount: false,
id: ROOT_COMPONENT_ID,
mountCount: 0,
mountCleanupSlots: [],
parentId: null,
props: {},
Expand Down Expand Up @@ -10310,6 +10343,7 @@ export const beginAsyncSSRContainer = async <T>(
childComponentIds: new Set(),
didMount: false,
id: ROOT_COMPONENT_ID,
mountCount: 0,
mountCleanupSlots: [],
parentId: null,
props: {},
Expand Down Expand Up @@ -10357,6 +10391,7 @@ export const disposeDetachedRuntimeComponent = (
) => {
clearComponentSubscriptions(container, component.id)
disposeComponentMountCleanups(component)
pruneComponentMounts(component, 0)
pruneComponentVisibles(container, component, 0)
pruneComponentWatches(container, component, 0)
for (const signalId of component.signalIds) {
Expand Down Expand Up @@ -10421,6 +10456,7 @@ const createResumePayload = (
scope: ensureComponentScopeId(container, component),
signalIds: [...component.signalIds],
symbol: component.symbol,
mountCount: component.mountCount,
visibleCount: component.visibleCount,
watchCount: component.watchCount,
} satisfies ResumeComponentPayload,
Expand Down Expand Up @@ -10546,6 +10582,7 @@ export const mergeResumePayload = (container: RuntimeContainer, payload: ResumeP
externalInstance: undefined,
externalMeta: null,
id,
mountCount: componentPayload.mountCount ?? 0,
mountCleanupSlots: null,
optimizedRoot: componentPayload.optimizedRoot === true,
parentId: id.includes('.') ? id.slice(0, id.lastIndexOf('.')) : ROOT_COMPONENT_ID,
Expand All @@ -10562,7 +10599,7 @@ export const mergeResumePayload = (container: RuntimeContainer, payload: ResumeP
subscribedSignalIds: null,
suspensePromise: null,
visibleCount: componentPayload.visibleCount ?? 0,
watchCount: componentPayload.watchCount,
watchCount: componentPayload.watchCount ?? 0,
})
}

Expand Down Expand Up @@ -12473,10 +12510,15 @@ export const createOnCleanup = (fn: () => void) => {

export const createOnMount = (fn: () => void) => {
const frame = getCurrentFrame()
if (!frame || frame.component.id === ROOT_COMPONENT_ID || frame.mode !== 'client') {
if (!frame || frame.component.id === ROOT_COMPONENT_ID) {
return
}
registerComponentState(frame.container, frame.component)
frame.mountCursor += 1
if (frame.mode !== 'client') {
frame.component.mountCount = Math.max(frame.component.mountCount, frame.mountCursor)
return
}
ensureFrameMountCallbacks(frame).push(fn)
}

Expand Down
3 changes: 3 additions & 0 deletions packages/eclipsa/core/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface ResumeComponentPayload {
scope: string
signalIds: string[]
symbol: string
mountCount?: number
visibleCount: number
watchCount: number
}
Expand Down Expand Up @@ -187,6 +188,7 @@ export interface ComponentState {
subscribedSignalIds: Set<string> | null
symbol: string
suspensePromise?: Promise<unknown> | null
mountCount: number
visibleCount: number
watchCount: number
}
Expand All @@ -201,6 +203,7 @@ export interface RenderFrame {
insertCursor: number
keyedRangeCursor: number
keyedRangeScopeStack: string[] | null
mountCursor: number
mountCallbacks: Array<() => void> | null
nextEffectCursor: number
nextRenderEffects: RenderEffect[] | null
Expand Down
Loading