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
6 changes: 6 additions & 0 deletions .changeset/shaky-berries-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/solid-start-client': patch
'@tanstack/start-client-core': patch
---

compact deferred hydration private state
48 changes: 25 additions & 23 deletions packages/solid-start-client/src/GenericHydrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ type HydrationMarkerDynamicProps = DynamicProps<'div'> & {
[key: `data-${string}`]: string | undefined
}
type PrefetchController = {
abortController: AbortController
hydrationRequested: boolean
hydrationListeners: Set<() => void>
hydrationResolvePending: boolean
abort: AbortController
requested: boolean
listeners: Set<() => void>
resolvePending: boolean
started: boolean
promise?: Promise<void>
}
Expand Down Expand Up @@ -107,10 +107,10 @@ export function GenericHydrate(props: InternalHydrateProps) {
)
const [prefetchError, setPrefetchError] = Solid.createSignal<unknown>()
const controller: PrefetchController = {
abortController: new AbortController(),
hydrationRequested: false,
hydrationListeners: new Set<() => void>(),
hydrationResolvePending: false,
abort: new AbortController(),
requested: false,
listeners: new Set<() => void>(),
resolvePending: false,
started: false,
}
let didPrefetch = false
Expand All @@ -127,35 +127,37 @@ export function GenericHydrate(props: InternalHydrateProps) {
}

const onHydrate = (listener: () => void) => {
if (controller.hydrationRequested) {
if (controller.requested) {
listener()
return () => {}
}

controller.hydrationListeners.add(listener)
controller.listeners.add(listener)
return () => {
controller.hydrationListeners.delete(listener)
controller.listeners.delete(listener)
}
}

const requestHydration = () => {
if (!controller.hydrationRequested) {
controller.hydrationRequested = true
controller.hydrationListeners.forEach((listener) => listener())
controller.hydrationListeners.clear()
if (!controller.requested) {
controller.requested = true
controller.listeners.forEach((listener) => listener())
controller.listeners.clear()
}

if (!controller.promise) {
resolveGate()
return
}
if (controller.hydrationResolvePending) return
controller.hydrationResolvePending = true
if (controller.resolvePending) {
return
}
controller.resolvePending = true

controller.promise.then(
() => resolveGate(),
(error) => {
if (!controller.abortController.signal.aborted) {
if (!controller.abort.signal.aborted) {
setPrefetchError(() => error)
}
},
Expand Down Expand Up @@ -195,12 +197,12 @@ export function GenericHydrate(props: InternalHydrateProps) {
.then(() =>
currentPrefetchStrategy({
element: markerElement ?? null,
signal: controller.abortController.signal,
signal: controller.abort.signal,
preload,
waitFor: (strategy) =>
waitForHydrationPrefetchStrategy(strategy, {
element: markerElement ?? null,
signal: controller.abortController.signal,
signal: controller.abort.signal,
onHydrate,
}),
}),
Expand All @@ -209,7 +211,7 @@ export function GenericHydrate(props: InternalHydrateProps) {

controller.promise = promise
promise.catch((error) => {
if (!controller.abortController.signal.aborted) {
if (!controller.abort.signal.aborted) {
setPrefetchError(() => error)
}
})
Expand Down Expand Up @@ -267,8 +269,8 @@ export function GenericHydrate(props: InternalHydrateProps) {
}

Solid.onCleanup(() => {
controller.abortController.abort()
controller.hydrationListeners.clear()
controller.abort.abort()
controller.listeners.clear()
cleanup()
releaseGate(gate)
})
Expand Down
22 changes: 12 additions & 10 deletions packages/start-client-core/src/hydration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,31 +115,33 @@ export function waitForHydrationPrefetchStrategy(
}

return new Promise((resolve) => {
const state = { disposed: false }
const cleanupStrategyRef: { current: void | (() => void) } = {
current: undefined,
}
let disposed = false
// The strategy may finish synchronously before returning its cleanup.
let cleanupStrategy: void | (() => void) = undefined
let cleanupHydrate = () => {}

const finish = (reason: HydrationPrefetchWaitReason) => {
if (state.disposed) return
state.disposed = true
if (disposed) {
return
}
disposed = true
options.signal.removeEventListener('abort', onAbort)
cleanupHydrate()
runHydrationStrategyCleanup(cleanupStrategyRef.current)?.()
runHydrationStrategyCleanup(cleanupStrategy)?.()
resolve(reason)
}

const onAbort = () => finish('abort')

options.signal.addEventListener('abort', onAbort, { once: true })
cleanupHydrate = options.onHydrate(() => finish('hydrate'))
const cleanupStrategy = strategy._s?.({
cleanupStrategy = strategy._s?.({
element: options.element,
prefetch: () => finish('prefetch'),
})
cleanupStrategyRef.current = cleanupStrategy
if (state.disposed) {
// A synchronous finish must immediately run the cleanup just returned.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (disposed) {
runHydrationStrategyCleanup(cleanupStrategy)?.()
}
})
Expand Down
79 changes: 43 additions & 36 deletions packages/start-client-core/src/hydration/visible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ export type VisibleHydrationOptions = {
threshold?: number | Array<number>
}

type VisibleObserverEntry = {
key: string
observer: IntersectionObserver
elements: Map<Element, Set<() => void>>
}
type VisibleObserverEntry = [
observer: IntersectionObserver,
elements: Map<Element, Set<() => void>>,
]

const observerRegistry = /* @__PURE__ */ new Map<string, VisibleObserverEntry>()

function cleanupVisibleObserverEntry(observerEntry: VisibleObserverEntry) {
if (observerEntry.elements.size > 0) return
observerEntry.observer.disconnect()
observerRegistry.delete(observerEntry.key)
function cleanupVisibleObserverEntry(
key: string,
observer: IntersectionObserver,
elements: Map<Element, Set<() => void>>,
) {
if (elements.size > 0) {
return
}
observer.disconnect()
observerRegistry.delete(key)
}

/* @__NO_SIDE_EFFECTS__ */
Expand All @@ -44,46 +49,48 @@ export function visible(
let observerEntry = observerRegistry.get(key)

if (!observerEntry) {
const entry: VisibleObserverEntry = {
key,
elements: new Map<Element, Set<() => void>>(),
observer: new IntersectionObserver(
(entries) => {
for (const intersectingEntry of entries) {
if (!intersectingEntry.isIntersecting) continue

const callbacks = entry.elements.get(intersectingEntry.target)
if (!callbacks) continue
const elements = new Map<Element, Set<() => void>>()
const observer = new IntersectionObserver(
(entries) => {
for (const intersectingEntry of entries) {
if (!intersectingEntry.isIntersecting) {
continue
}

callbacks.forEach((callback) => callback())
entry.elements.delete(intersectingEntry.target)
entry.observer.unobserve(intersectingEntry.target)
cleanupVisibleObserverEntry(entry)
const callbacks = elements.get(intersectingEntry.target)
if (!callbacks) {
continue
}
},
{ rootMargin, threshold },
),
}
observerRegistry.set(key, entry)
observerEntry = entry

callbacks.forEach((callback) => callback())
elements.delete(intersectingEntry.target)
observer.unobserve(intersectingEntry.target)
cleanupVisibleObserverEntry(key, observer, elements)
}
},
{ rootMargin, threshold },
)
observerEntry = [observer, elements]
observerRegistry.set(key, observerEntry)
}

let callbacks = observerEntry.elements.get(element)
const [observer, elements] = observerEntry
let callbacks = elements.get(element)
if (!callbacks) {
callbacks = new Set()
observerEntry.elements.set(element, callbacks)
observerEntry.observer.observe(element)
elements.set(element, callbacks)
observer.observe(element)
}
callbacks.add(callback)

return () => {
const currentCallbacks = observerEntry.elements.get(element)
const currentCallbacks = elements.get(element)
currentCallbacks?.delete(callback)
if (currentCallbacks?.size === 0) {
observerEntry.elements.delete(element)
observerEntry.observer.unobserve(element)
elements.delete(element)
observer.unobserve(element)
}
cleanupVisibleObserverEntry(observerEntry)
cleanupVisibleObserverEntry(key, observer, elements)
}
},
}
Expand Down
100 changes: 100 additions & 0 deletions packages/start-client-core/tests/hydration-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import { waitForHydrationPrefetchStrategy } from '../src/hydration/runtime'
import type { HydrationPrefetchStrategy } from '../src/hydration/types'

describe('waitForHydrationPrefetchStrategy', () => {
it('cleans up a strategy that finishes during synchronous setup', async () => {
const abortController = new AbortController()
const cleanupHydrate = vi.fn()
const cleanupStrategy = vi.fn()
let hydrate = () => {}

const strategy: HydrationPrefetchStrategy = {
_s: ({ prefetch }) => {
prefetch?.()
return cleanupStrategy
},
}

const result = waitForHydrationPrefetchStrategy(strategy, {
element: null,
signal: abortController.signal,
onHydrate: (listener) => {
hydrate = listener
return cleanupHydrate
},
})

await expect(result).resolves.toBe('prefetch')
expect(cleanupHydrate).toHaveBeenCalledTimes(1)
expect(cleanupStrategy).toHaveBeenCalledTimes(1)

hydrate()
abortController.abort()
expect(cleanupHydrate).toHaveBeenCalledTimes(1)
expect(cleanupStrategy).toHaveBeenCalledTimes(1)
})

it.each([
['abort', 'abort'],
['hydrate', 'hydrate'],
] as const)(
'settles an abort/hydrate race with %s first and cleans up once',
async (winner, expectedReason) => {
const abortController = new AbortController()
const cleanupHydrate = vi.fn()
const cleanupStrategy = vi.fn()
let hydrate = () => {}
let prefetch = () => {}

const strategy: HydrationPrefetchStrategy = {
_s: (context) => {
prefetch = context.prefetch ?? (() => {})
return cleanupStrategy
},
}

const result = waitForHydrationPrefetchStrategy(strategy, {
element: null,
signal: abortController.signal,
onHydrate: (listener) => {
hydrate = listener
return cleanupHydrate
},
})

if (winner === 'abort') {
abortController.abort()
hydrate()
} else {
hydrate()
abortController.abort()
}
prefetch()

await expect(result).resolves.toBe(expectedReason)
expect(cleanupHydrate).toHaveBeenCalledTimes(1)
expect(cleanupStrategy).toHaveBeenCalledTimes(1)
},
)

it('does not set up a strategy when the signal is already aborted', async () => {
const abortController = new AbortController()
abortController.abort()
const setup = vi.fn()
const onHydrate = vi.fn()

const result = waitForHydrationPrefetchStrategy(
{ _s: setup },
{
element: null,
signal: abortController.signal,
onHydrate,
},
)

await expect(result).resolves.toBe('abort')
expect(setup).not.toHaveBeenCalled()
expect(onHydrate).not.toHaveBeenCalled()
})
})
Loading
Loading