diff --git a/e2e/app/hash-nav/+page.tsx b/e2e/app/hash-nav/+page.tsx index 5996d76..3e3c75e 100644 --- a/e2e/app/hash-nav/+page.tsx +++ b/e2e/app/hash-nav/+page.tsx @@ -12,5 +12,8 @@ export default () => ( Deep Dive Target
The target should be scrolled into view.
++ Open counter after deep scroll +
) diff --git a/e2e/test/example.dev.test.ts b/e2e/test/example.dev.test.ts index 6c4d382..88e2da5 100644 --- a/e2e/test/example.dev.test.ts +++ b/e2e/test/example.dev.test.ts @@ -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') @@ -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, }) => { diff --git a/packages/eclipsa/core/resume-loader.test.ts b/packages/eclipsa/core/resume-loader.test.ts index 82bab0f..9b433a6 100644 --- a/packages/eclipsa/core/resume-loader.test.ts +++ b/packages/eclipsa/core/resume-loader.test.ts @@ -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) + }) }) diff --git a/packages/eclipsa/core/resume-loader.ts b/packages/eclipsa/core/resume-loader.ts index 551a6b2..9a41898 100644 --- a/packages/eclipsa/core/resume-loader.ts +++ b/packages/eclipsa/core/resume-loader.ts @@ -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, + ) } diff --git a/packages/eclipsa/core/runtime.ts b/packages/eclipsa/core/runtime.ts index 680cd6c..43d75f8 100644 --- a/packages/eclipsa/core/runtime.ts +++ b/packages/eclipsa/core/runtime.ts @@ -589,6 +589,7 @@ const createInactiveComponentState = ( externalInstance: undefined, externalMeta: null, id, + mountCount: 0, mountCleanupSlots: null, mayChangeNodeCount: false, optimizedRoot: false, @@ -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 @@ -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 @@ -3105,6 +3108,7 @@ const createFrame = ( insertCursor: 0, keyedRangeCursor: 0, keyedRangeScopeStack: null, + mountCursor: 0, mountCallbacks: null, mode, nextEffectCursor: 0, @@ -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) } @@ -3448,6 +3453,10 @@ const pruneComponentWatches = ( component.watchCount = nextCount } +const pruneComponentMounts = (component: ComponentState, nextCount: number) => { + component.mountCount = nextCount +} + const pruneComponentVisibles = ( container: RuntimeContainer, component: ComponentState, @@ -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() @@ -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) { @@ -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')}` @@ -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 = @@ -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) @@ -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) @@ -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) } @@ -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')}` @@ -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) @@ -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 } @@ -9224,7 +9250,9 @@ const commitRouteNavigation = ( if (options?.writeLocation !== false) { writeRouterLocation(router, url) } - scrollToUrlFragment(doc, url) + scrollToUrlTarget(doc, url, { + resetScroll: mode !== 'pop', + }) } const renderAndCommitRouteNavigation = ( @@ -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 } @@ -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 = @@ -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 = @@ -10264,6 +10296,7 @@ export const beginSSRContainer =