diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c12d67b..d9f73a6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,38 @@ permissions:
id-token: write
jobs:
+ e2e:
+ name: E2E
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ concurrency:
+ group: ci-e2e-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.10
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: packages/eclipsa/compiler/rust
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+ - name: Install Chromium
+ working-directory: e2e
+ run: bun x playwright install --with-deps chromium
+ - name: Run E2E suite
+ run: bun run test:e2e --log-order=stream
+
quality:
name: Quality
runs-on: ubuntu-latest
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index 9a8da9f..80e3966 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -37,6 +37,8 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-wasip1-threads
- name: Cache Rust
uses: Swatinem/rust-cache@v2
@@ -49,6 +51,13 @@ jobs:
- name: Build optimizer native artifacts
run: bun run --filter @eclipsa/optimizer build:native:dev
+ - name: Build playground browser compiler
+ working-directory: packages/optimizer
+ run: bun ./scripts/run-napi.ts build --platform --release --manifest-path ../eclipsa/compiler/rust/Cargo.toml --package-json-path ./package.json --target-dir ../eclipsa/compiler/rust/target --output-dir ./generated --js-package-name @eclipsa/optimizer --target wasm32-wasip1-threads
+
+ - name: Verify browser compiler artifact
+ run: node --input-type=module -e "import { readFile } from 'node:fs/promises'; await WebAssembly.compile(await readFile('packages/optimizer/generated/optimizer.wasm32-wasi.wasm'));"
+
- name: Build website
run: bun run --cwd docs build
diff --git a/docs/app/docs/+page.tsx b/docs/app/docs/+page.tsx
index cc401e1..f49c438 100644
--- a/docs/app/docs/+page.tsx
+++ b/docs/app/docs/+page.tsx
@@ -1,4 +1,4 @@
-import { onVisible, useNavigate, type MetadataContext } from 'eclipsa'
+import { Link, onVisible, useNavigate, type MetadataContext } from 'eclipsa'
export const metadata = ({ url }: MetadataContext) => ({
canonical: url.pathname,
@@ -8,7 +8,11 @@ export const metadata = ({ url }: MetadataContext) => ({
export default () => {
const nav = useNavigate()
onVisible(() => {
- nav('/docs/getting-started/overview')
+ void nav('/docs/getting-started/overview', { replace: true })
})
- return
+ return (
+
+ Open the documentation
+
+ )
}
diff --git a/docs/app/docs/[...slug]/+layout.tsx b/docs/app/docs/[...slug]/+layout.tsx
index 591f7ca..c2b3262 100644
--- a/docs/app/docs/[...slug]/+layout.tsx
+++ b/docs/app/docs/[...slug]/+layout.tsx
@@ -204,16 +204,6 @@ const OnThisPageNav = (props: {
const OnThisPageLink = (props: { heading: TocHeading }) => {
const { heading } = props
- const jumpToHeading = (slug: string) => {
- if (typeof document === 'undefined') return
- const target = document.getElementById(slug)
- if (!(target instanceof HTMLElement)) return
- target.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- })
- history.replaceState(null, '', `#${slug}`)
- }
return (
{
heading.depth > 2 ? 'ml-4 text-[13px] text-[color:var(--docs-text-soft)]' : '',
)}
href={`#${heading.slug}`}
- onClick={(event) => {
- event.preventDefault()
- jumpToHeading(heading.slug)
- }}
>
{heading.text}
diff --git a/e2e/app/hash-nav/+page.tsx b/e2e/app/hash-nav/+page.tsx
index 5996d76..62701bf 100644
--- a/e2e/app/hash-nav/+page.tsx
+++ b/e2e/app/hash-nav/+page.tsx
@@ -5,12 +5,17 @@ export default () => (
Hash Navigation
Client-side fragment navigation should preserve native anchor scrolling.
- Jump to deep dive
+
+ Jump to deep dive
+
Deep Dive Target
The target should be scrolled into view.
+
+ Open counter after deep scroll
+
)
diff --git a/e2e/app/mount-only/+page.tsx b/e2e/app/mount-only/+page.tsx
new file mode 100644
index 0000000..a4a4a61
--- /dev/null
+++ b/e2e/app/mount-only/+page.tsx
@@ -0,0 +1,9 @@
+import { onMount } from 'eclipsa'
+
+export default () => {
+ onMount(() => {
+ const element = document.querySelector('[data-testid="mount-only-state"]')!
+ element.textContent = 'mounted'
+ })
+ return waiting
+}
diff --git a/e2e/app/query-nav/+page.tsx b/e2e/app/query-nav/+page.tsx
new file mode 100644
index 0000000..24796e5
--- /dev/null
+++ b/e2e/app/query-nav/+page.tsx
@@ -0,0 +1,14 @@
+import { Link, loader } from 'eclipsa'
+
+const useQuery = loader((c) => ({ value: c.req.query('q') ?? 'one' }))
+
+export default () => {
+ const query = useQuery()
+ return (
+
+ Query navigation
+ {query.data?.value ?? 'loading'}
+ Load second query
+
+ )
+}
diff --git a/e2e/app/route-props/+layout.tsx b/e2e/app/route-props/+layout.tsx
new file mode 100644
index 0000000..2e46ef9
--- /dev/null
+++ b/e2e/app/route-props/+layout.tsx
@@ -0,0 +1,33 @@
+import { Link, useLocation, useSignal } from 'eclipsa'
+import type { JSX } from 'eclipsa/jsx-runtime'
+
+const RouteLabel = (props: { label: string }) => {
+ const count = useSignal(0)
+ return (
+
+ )
+}
+
+export default (props: { children: JSX.Childable }) => {
+ const location = useLocation()
+ const labels = location.pathname.endsWith('/b')
+ ? ['Second heading', 'Extra heading']
+ : ['First heading']
+ return (
+
+
+
+
Route A
+
Route B
+ {props.children}
+
+ )
+}
diff --git a/e2e/app/route-props/a/+page.tsx b/e2e/app/route-props/a/+page.tsx
new file mode 100644
index 0000000..8540ce8
--- /dev/null
+++ b/e2e/app/route-props/a/+page.tsx
@@ -0,0 +1 @@
+export default () => Route A body
diff --git a/e2e/app/route-props/b/+page.tsx b/e2e/app/route-props/b/+page.tsx
new file mode 100644
index 0000000..616d534
--- /dev/null
+++ b/e2e/app/route-props/b/+page.tsx
@@ -0,0 +1 @@
+export default () => Route B body
diff --git a/e2e/app/slot-motion-nav/+layout.tsx b/e2e/app/slot-motion-nav/+layout.tsx
index 418301a..757891f 100644
--- a/e2e/app/slot-motion-nav/+layout.tsx
+++ b/e2e/app/slot-motion-nav/+layout.tsx
@@ -18,6 +18,17 @@ export default (props: { children?: unknown }) => {
>
Toggle nav
+
{open.value ? 'open' : 'closed'}
{
+ const navigate = useNavigate()
+ onVisible(() => {
+ void navigate('/counter', { replace: true })
+ })
+ return Opening counter
+}
diff --git a/e2e/scripts/run-playwright.ts b/e2e/scripts/run-playwright.ts
index d8e38ef..0e6f2b3 100644
--- a/e2e/scripts/run-playwright.ts
+++ b/e2e/scripts/run-playwright.ts
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
import { createServer } from 'node:net'
import path from 'node:path'
import { resolveNodeBinary } from './node-binary.ts'
+import { stopChildProcess } from './stop-child-process.ts'
const host = '127.0.0.1'
const cwd = process.cwd()
@@ -91,6 +92,7 @@ const run = async () => {
const devServer = spawn(nodeBinaryPath, devArgs, {
cwd,
env,
+ detached: process.platform !== 'win32',
stdio: ['ignore', 'inherit', 'inherit'],
})
const waitForDevServerExit = () =>
@@ -105,17 +107,14 @@ const run = async () => {
})
})
- const terminateDevServer = () => {
- if (!devServer.killed) {
- devServer.kill('SIGTERM')
- }
- }
+ let shutdownPromise: Promise | undefined
+ const terminateDevServer = () =>
+ (shutdownPromise ??= stopChildProcess(devServer, 5_000, process.platform !== 'win32'))
const exitSignals = ['SIGINT', 'SIGTERM'] as const
for (const signal of exitSignals) {
process.on(signal, () => {
- terminateDevServer()
- process.exit(1)
+ void terminateDevServer().finally(() => process.exit(1))
})
}
@@ -143,14 +142,10 @@ const run = async () => {
})
if (exitCode !== 0) {
- process.exit(exitCode)
+ process.exitCode = exitCode
}
} finally {
- terminateDevServer()
- await new Promise((resolve) => {
- devServer.once('close', resolve)
- setTimeout(resolve, 5_000)
- })
+ await terminateDevServer()
}
}
diff --git a/e2e/scripts/stop-child-process.test.ts b/e2e/scripts/stop-child-process.test.ts
new file mode 100644
index 0000000..613a4ea
--- /dev/null
+++ b/e2e/scripts/stop-child-process.test.ts
@@ -0,0 +1,57 @@
+import type { ChildProcess } from 'node:child_process'
+import { EventEmitter } from 'node:events'
+import { expect, test, vi } from 'vitest'
+import { stopChildProcess } from './stop-child-process.ts'
+
+const fakeChild = (graceful: boolean) => {
+ const child = new EventEmitter() as ChildProcess
+ child.exitCode = null
+ child.signalCode = null
+ const signals: string[] = []
+ child.kill = (signal) => {
+ signals.push(String(signal))
+ if (graceful || signal === 'SIGKILL') {
+ queueMicrotask(() => {
+ child.signalCode = signal as NodeJS.Signals
+ child.emit('close', null, signal)
+ })
+ }
+ return true
+ }
+ return { child, signals }
+}
+
+test('stops a child gracefully without leaving a shutdown timer', async () => {
+ const { child, signals } = fakeChild(true)
+ await stopChildProcess(child, 10)
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ expect(signals).toEqual(['SIGTERM'])
+})
+
+test('forces shutdown when the dev server ignores SIGTERM', async () => {
+ const { child, signals } = fakeChild(false)
+ await stopChildProcess(child, 10)
+ expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
+})
+
+test('leaves an already exited child alone', async () => {
+ const { child, signals } = fakeChild(true)
+ child.exitCode = 0
+ await stopChildProcess(child, 10)
+ expect(signals).toEqual([])
+})
+
+test('stops descendants even when their launcher has already exited', async () => {
+ const { child } = fakeChild(true)
+ Object.assign(child, { pid: 12345, exitCode: 0 })
+ const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
+ try {
+ await stopChildProcess(child, 10, true)
+ expect(kill.mock.calls).toEqual([
+ [-12345, 'SIGTERM'],
+ [-12345, 'SIGKILL'],
+ ])
+ } finally {
+ kill.mockRestore()
+ }
+})
diff --git a/e2e/scripts/stop-child-process.ts b/e2e/scripts/stop-child-process.ts
new file mode 100644
index 0000000..bbb1c11
--- /dev/null
+++ b/e2e/scripts/stop-child-process.ts
@@ -0,0 +1,35 @@
+import type { ChildProcess } from 'node:child_process'
+
+export const stopChildProcess = async (
+ child: ChildProcess,
+ graceMs = 5_000,
+ processGroup = false,
+) => {
+ const kill = (signal: NodeJS.Signals) => {
+ if (processGroup && child.pid !== undefined) {
+ try {
+ process.kill(-child.pid, signal)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
+ }
+ } else {
+ child.kill(signal)
+ }
+ }
+ if (processGroup) {
+ kill('SIGTERM')
+ await new Promise((resolve) => setTimeout(resolve, graceMs))
+ // The launcher can exit before its Vite descendants release inherited pipes.
+ kill('SIGKILL')
+ return
+ }
+ if (child.exitCode !== null || child.signalCode !== null) return
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => kill('SIGKILL'), graceMs)
+ child.once('close', () => {
+ clearTimeout(timeout)
+ resolve()
+ })
+ kill('SIGTERM')
+ })
+}
diff --git a/e2e/test/example.dev.test.ts b/e2e/test/example.dev.test.ts
index 6c4d382..cb3434e 100644
--- a/e2e/test/example.dev.test.ts
+++ b/e2e/test/example.dev.test.ts
@@ -283,6 +283,48 @@ test.describe('example app in dev mode', () => {
.toBe(true)
})
+ test('navigates to a fragment without requesting route data', async ({ page }) => {
+ await page.goto('/hash-nav')
+ await waitForResumedRoute(page)
+ const requests: string[] = []
+ page.on('request', (request) => {
+ if (['fetch', 'xhr', 'document'].includes(request.resourceType())) {
+ requests.push(request.url())
+ }
+ })
+ await page.getByRole('link', { name: 'Jump to deep dive' }).click()
+ await expect(page).toHaveURL(/\/hash-nav#deep-dive$/)
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1000)
+ expect(requests).toEqual([])
+ })
+
+ test('refreshes loader results on query-only navigation and browser back', async ({ page }) => {
+ await page.goto('/query-nav?q=one')
+ await expect(page.getByTestId('query-result')).toHaveText('one')
+ await page.getByRole('link', { name: 'Load second query' }).click()
+ await expect(page).toHaveURL(/\/query-nav\?q=two$/)
+ await expect(page.getByTestId('query-result')).toHaveText('two')
+ await page.goBack()
+ await expect(page).toHaveURL(/\/query-nav\?q=one$/)
+ await expect(page.getByTestId('query-result')).toHaveText('one')
+ })
+
+ 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')
@@ -354,6 +396,17 @@ test.describe('example app in dev mode', () => {
await expect(page.getByTestId('motion-indicator')).toHaveText('Left active')
})
+ test('preserves local counter state after a shared atom update', async ({ page }) => {
+ await page.goto('/atom')
+ await page.getByTestId('atom-local').click()
+ await expect(page.getByTestId('atom-local')).toHaveText('Local count: 1')
+ await page.getByTestId('atom-left').click()
+ await expect(page.getByTestId('atom-summary')).toHaveText('Shared atom count: 1')
+ await expect(page.getByTestId('atom-local')).toHaveText('Local count: 1')
+ await page.getByTestId('atom-local').click()
+ await expect(page.getByTestId('atom-local')).toHaveText('Local count: 2')
+ })
+
test('shares atom state across components and preserves it across Link navigation', async ({
page,
}) => {
@@ -584,6 +637,59 @@ test.describe('example app in dev mode', () => {
)
})
+ test('updates motion after an asynchronous signal write on a navigated route', async ({
+ page,
+ }) => {
+ await page.goto('/slot-motion-nav/overview')
+ await page.getByTestId('slot-motion-nav-quick-start-link').click()
+ await expect(page.getByRole('heading', { name: 'quick-start' })).toBeVisible()
+ await page.getByTestId('slot-motion-nav-async-toggle').click()
+ await expect(page.getByTestId('slot-motion-nav-toggle-state')).toHaveText('closed')
+ await expect(page.getByTestId('slot-motion-nav-panel')).toHaveCSS('max-height', '0px')
+ })
+
+ test('runs onMount on direct load without signals or external components', async ({ page }) => {
+ await page.goto('/mount-only')
+ await expect(page.getByTestId('mount-only-state')).toHaveText('mounted')
+ })
+
+ test('updates child props and reactive bindings across shared-layout navigation', async ({
+ page,
+ }) => {
+ await page.goto('/route-props/a')
+ await expect(page.getByRole('button', { name: '/route-props/a: 0', exact: true })).toBeVisible()
+ await page.getByRole('link', { name: 'Route B', exact: true }).click()
+ await expect(page.getByText('Route B body', { exact: true })).toBeVisible()
+ await expect(page.getByRole('link', { name: 'First heading', exact: true })).toHaveCount(0)
+ await expect(page.getByRole('link', { name: 'Second heading', exact: true })).toBeVisible()
+ await expect(page.getByRole('link', { name: 'Extra heading', exact: true })).toBeVisible()
+ await page.getByRole('button', { name: '/route-props/b: 0', exact: true }).click()
+ await expect(page.getByRole('button', { name: '/route-props/b: 1', exact: true })).toBeVisible()
+ await page.goBack()
+ await expect(page.getByRole('button', { name: '/route-props/a: 1', exact: true })).toBeVisible()
+ })
+
+ test('finishes startup restoration before navigating from onVisible', async ({ page }) => {
+ await page.goto('/visible-redirect')
+ await expect(page).toHaveURL(/\/counter$/)
+ await expect(page.getByText('Counter page')).toHaveCount(1)
+ await page.getByRole('button', { name: /^Count:\s*0$/ }).click()
+ await expect(page.getByRole('button', { name: /^Count:\s*1$/ })).toBeVisible()
+ })
+
+ 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,
}) => {
@@ -1090,6 +1196,7 @@ test.describe('example app in dev mode', () => {
try {
await page.goto('/content')
+ await waitForResumedRoute(page)
await expect(page.getByTestId('content-description')).toHaveText(
contentDescriptionBeforeLabel,
)
diff --git a/packages/eclipsa/core/resume-full.ts b/packages/eclipsa/core/resume-full.ts
index f2f2474..defd1ec 100644
--- a/packages/eclipsa/core/resume-full.ts
+++ b/packages/eclipsa/core/resume-full.ts
@@ -7,7 +7,7 @@ import {
primeRouteModules,
restoreRegisteredRpcHandles,
restoreResumedExternalComponents,
- restoreResumedLocalSignalEffects,
+ restoreResumedComponentEffects,
registerResumeContainer,
type ResumePayload,
} from './runtime.ts'
@@ -120,10 +120,13 @@ export const resumeContainer = async (
await primeRouteModules(container)
restoreRegisteredRpcHandles(container)
await restoreResumedExternalComponents(container)
- await restoreResumedLocalSignalEffects(container)
+ await restoreResumedComponentEffects(container)
registerResumeContainer(container)
root.setAttribute('data-e-resume', 'resumed')
+ resolveResumeReady()
+ container.resumeReadyPromise = null
+
if (options?.replayEvent) {
await dispatchDocumentEventReady(container, options.replayEvent)
}
diff --git a/packages/eclipsa/core/resume-links.test.ts b/packages/eclipsa/core/resume-links.test.ts
index d1dc052..ed40c50 100644
--- a/packages/eclipsa/core/resume-links.test.ts
+++ b/packages/eclipsa/core/resume-links.test.ts
@@ -192,7 +192,7 @@ describe('resumeContainer interactivity bootstrap', () => {
registerResumeContainer: vi.fn(),
restoreRegisteredRpcHandles: vi.fn(),
restoreResumedExternalComponents: vi.fn(),
- restoreResumedLocalSignalEffects: vi.fn(),
+ restoreResumedComponentEffects: vi.fn(),
}))
vi.doMock('./hooks.ts', () => ({
registerClientHooks: vi.fn(),
diff --git a/packages/eclipsa/core/resume-loader.test.ts b/packages/eclipsa/core/resume-loader.test.ts
index 82bab0f..66394e5 100644
--- a/packages/eclipsa/core/resume-loader.test.ts
+++ b/packages/eclipsa/core/resume-loader.test.ts
@@ -238,4 +238,72 @@ 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)
+ })
+})
+
+describe('native link navigation on first interaction', () => {
+ for (const kind of [
+ 'external',
+ 'download',
+ 'target',
+ 'prevented',
+ 'invalid',
+ 'ctrl',
+ 'meta',
+ 'shift',
+ 'alt',
+ 'middle',
+ ]) {
+ it(`preserves ${kind} clicks before full resume`, () => {
+ const doc = new FakeDocument()
+ const link = new FakeElement(doc, 'a')
+ link.parentElement = doc.body
+ link.setAttribute('data-e-link', '')
+ link.setAttribute('href', '/docs')
+ if (kind === 'external') link.setAttribute('href', 'https://external.example/docs')
+ if (kind === 'download') link.setAttribute('download', '')
+ if (kind === 'target') link.target = '_blank'
+ if (kind === 'invalid') link.setAttribute('href', 'http://[')
+ const OriginalMouseEvent = globalThis.MouseEvent
+ class LinkMouseEvent extends Event {
+ button = kind === 'middle' ? 1 : 0
+ ctrlKey = kind === 'ctrl'
+ metaKey = kind === 'meta'
+ shiftKey = kind === 'shift'
+ altKey = kind === 'alt'
+ override get target() {
+ return link as unknown as EventTarget
+ }
+ }
+ globalThis.MouseEvent = LinkMouseEvent as unknown as typeof MouseEvent
+ const loadFullResume = vi.fn()
+ const cleanup = installResumeLoader(doc.body as unknown as HTMLElement, createPayload({}), {
+ loadFullResume,
+ })
+ try {
+ const event = new LinkMouseEvent('click', { cancelable: true })
+ if (kind === 'prevented') event.preventDefault()
+ doc.dispatch(event)
+ expect(loadFullResume).not.toHaveBeenCalled()
+ expect(event.defaultPrevented).toBe(kind === 'prevented')
+ } finally {
+ cleanup()
+ globalThis.MouseEvent = OriginalMouseEvent
+ }
+ })
+ }
})
diff --git a/packages/eclipsa/core/resume-loader.ts b/packages/eclipsa/core/resume-loader.ts
index 551a6b2..e4e40a7 100644
--- a/packages/eclipsa/core/resume-loader.ts
+++ b/packages/eclipsa/core/resume-loader.ts
@@ -166,37 +166,43 @@ const shouldPromoteForNativeFeature = (event: Event) => {
if (event.type === 'submit' && closestWithAttribute(event.target, ACTION_FORM_ATTR)) {
return true
}
- if (event.type === 'click' && closestWithAttribute(event.target, ROUTE_LINK_ATTR)) {
- return true
+ if (event.type === 'click') {
+ return capturePendingRouteLinkNavigation(event)
}
return false
}
const capturePendingRouteLinkNavigation = (event: Event) => {
- if (event.type !== 'click') {
- return
+ if (event.type !== 'click' || event.defaultPrevented) {
+ return false
}
if (typeof MouseEvent !== 'undefined' && event instanceof MouseEvent) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
- return
+ return false
}
}
const link = closestWithAttribute(event.target, ROUTE_LINK_ATTR) as HTMLAnchorElement | null
if (!link || link.hasAttribute('download') || (link.target && link.target !== '_self')) {
- return
+ return false
}
const href = link.getAttribute('href')
if (!href) {
- return
+ return false
+ }
+ let url: URL
+ try {
+ url = new URL(href, link.ownerDocument.location.href)
+ } catch {
+ return false
}
- const url = new URL(href, link.ownerDocument.location.href)
if (url.origin !== link.ownerDocument.location.origin) {
- return
+ return false
}
;(globalThis as Record)[PENDING_RESUME_LINK_KEY] = {
href: url.href,
replace: link.getAttribute(ROUTE_REPLACE_ATTR) === 'true',
}
+ return true
}
const createFullResumePromoter = (
@@ -235,7 +241,6 @@ export const installResumeLoader = (
const dispatch = (event: Event) => {
if (shouldPromoteForNativeFeature(event)) {
- capturePendingRouteLinkNavigation(event)
void promoteToFullResume(event)
return
}
@@ -304,5 +309,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.test.ts b/packages/eclipsa/core/runtime.test.ts
index c883e39..2999210 100644
--- a/packages/eclipsa/core/runtime.test.ts
+++ b/packages/eclipsa/core/runtime.test.ts
@@ -14,6 +14,7 @@ import {
textNodeSignalMember,
} from './client/dom.ts'
import {
+ attr as attrCompiled,
attrStatic as attrCompiledStatic,
classSignal as classCompiledSignal,
createComponent as createCompiledComponent,
@@ -23,12 +24,15 @@ import {
renderNodes as renderCompiledNodes,
text as textCompiled,
textNodeSignalMember as textCompiledNodeSignalMember,
+ textNodeSignalValue as textCompiledNodeSignalValue,
} from './runtime/dom-compiled.ts'
import { eventStatic as runtimeEventStatic } from './runtime/event.ts'
import {
onMount as onCompiledMount,
onVisible as onCompiledVisible,
- signal as createCompiledSignal,
+ useSignal as createCompiledSignal,
+ signal as createCompiledRowSignal,
+ isSignal as isCompiledSignal,
} from './runtime/reactive.ts'
import {
createContext,
@@ -45,13 +49,14 @@ import {
} from './internal.ts'
import { For, Show } from './flow/mod.ts'
import { __eclipsaLoader } from './loader.ts'
-import { Link, useLocation, useRouteParams } from './router.tsx'
+import { Link, useLocation, useNavigate, useRouteParams } from './router.tsx'
import { onCleanup, onMount, useComputed, useSignal, useWatch } from './signal.ts'
import { ACTION_FORM_ATTR, CLIENT_INSERT_OWNER_SYMBOL } from './runtime/constants.ts'
import {
bindPackedRuntimeEvent,
bindRuntimeEvent,
applyResumeHmrSymbolReplacements,
+ markResumeHmrBoundaryDirty,
createFixedSignalEffect,
createResumeContainer,
createDelegatedEvent,
@@ -69,7 +74,7 @@ import {
renderClientInsertable,
restoreSignalRefs,
runDetachedRuntimeComponent,
- restoreResumedLocalSignalEffects,
+ restoreResumedComponentEffects,
serializeContainerValue,
syncBoundElementSignal,
syncManagedElementAttributes,
@@ -78,6 +83,7 @@ import {
tryPatchNodeSequenceInPlace,
type RuntimeContainer,
withRuntimeContainer,
+ useRuntimeAtom,
} from './runtime.ts'
import {
getManagedAttributeSnapshot,
@@ -6192,7 +6198,7 @@ describe('renderClientInsertable', () => {
expect(getEffectCount()).toBe(0)
- await restoreResumedLocalSignalEffects(container)
+ await restoreResumedComponentEffects(container)
const livePanel = parent.childNodes[1] as FakeElement | undefined
expect(livePanel).toBeInstanceOf(FakeElement)
@@ -6289,7 +6295,7 @@ describe('renderClientInsertable', () => {
;(globalThis as typeof globalThis & { document: Document }).document =
container.doc as Document
try {
- await restoreResumedLocalSignalEffects(container)
+ await restoreResumedComponentEffects(container)
const input = findFirst('input')
const span = findFirst('span')
@@ -6509,7 +6515,7 @@ describe('renderClientInsertable', () => {
)
;(globalThis as typeof globalThis & { document: Document }).document = doc
- await restoreResumedLocalSignalEffects(container)
+ await restoreResumedComponentEffects(container)
expect(events).toEqual([])
@@ -13773,3 +13779,347 @@ describe('renderClientInsertable', () => {
})
})
})
+
+describe('docs navigation regressions', () => {
+ it('updates changed child props and keeps their effects after a router update', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ Object.assign(container.doc!, { location: new URL('https://example.com/first') })
+ primeLocationState(container, 'https://example.com/first')
+ let count!: { value: number }
+ const Child = __eclipsaComponent(
+ (props: { label: string }) => {
+ count = createCompiledSignal(0)
+ const input = container.doc!.createElement('input')
+ attrCompiled(input, 'value', () => `${props.label}:${count.value}`)
+ return input
+ },
+ 'route-child',
+ () => [],
+ )
+ const Parent = __eclipsaComponent(
+ () => {
+ const location = useLocation()
+ return jsxDEV(Child, { label: location.pathname }, null, false, {})
+ },
+ 'route-parent',
+ () => [],
+ )
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(Parent, {}, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ primeLocationState(container, 'https://example.com/second')
+ await flushDirtyComponents(container)
+ const input = (container.doc!.body as unknown as FakeElement).childNodes.find(
+ (node) => node instanceof FakeElement && node.tagName === 'input',
+ ) as FakeElement & { value: string }
+ expect(input.value).toBe('/second:0')
+ count.value = 1
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(input.value).toBe('/second:1')
+ })
+ })
+
+ it('initializes internal compiled row signals from new props on every render', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const source = createDetachedRuntimeSignal(container, 'row-source', 'first')
+ const App = __eclipsaComponent(
+ () => {
+ const row = createCompiledRowSignal(source.value)
+ return jsxDEV('p', { children: row.value }, null, false, {})
+ },
+ 'compiled-row-owner',
+ () => [],
+ )
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(App, {}, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ source.value = 'second'
+ await flushDirtyComponents(container)
+ expect(container.doc!.body.textContent).toContain('second')
+ })
+ })
+
+ it('updates compiled bindings without rerendering their owner', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ let count!: { value: number }
+ let input!: HTMLInputElement
+ let renders = 0
+ const App = __eclipsaComponent(
+ () => {
+ renders++
+ count = createCompiledSignal(0)
+ void count.value
+ const node = container.doc!.createTextNode('')
+ textCompiledNodeSignalValue(count, node)
+ input = container.doc!.createElement('input')
+ attrCompiled(input, 'value', () => String(count.value))
+ return [node, input]
+ },
+ 'compiled-binding-owner',
+ () => [],
+ )
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(App, {}, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ count.value = 1
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(renders).toBe(1)
+ expect(container.doc!.body.textContent).toContain('1')
+ expect(input.value).toBe('1')
+ })
+ })
+
+ it('does not subscribe the rendering component to explicit watch callback reads', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const dependency = createDetachedRuntimeSignal(container, 'dependency', 0)
+ const observed = createDetachedRuntimeSignal(container, 'observed', 0)
+ let renders = 0
+ const values: number[] = []
+ const App = __eclipsaComponent(
+ () => {
+ renders++
+ useWatch(() => {
+ values.push(observed.value)
+ }, [dependency])
+ return jsxDEV('p', { children: 'watch' }, null, false, {})
+ },
+ 'watch-owner',
+ () => [],
+ )
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(App, {}, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ observed.value = 1
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(renders).toBe(1)
+ expect(values).toEqual([0])
+ dependency.value = 1
+ expect(values).toEqual([0, 1])
+ })
+ })
+
+ it('preserves compiled local signals when an atom rerenders their component', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const atom = {}
+ let local!: { value: number }
+ let shared!: { value: number }
+ const render = () => {
+ shared = useRuntimeAtom(atom, 0)
+ local = createCompiledSignal(0)
+ const text = container.doc!.createTextNode('')
+ textCompiledNodeSignalValue(local, text)
+ return [jsxDEV('span', { children: `${shared.value}:` }, null, false, {}), text]
+ }
+ const App = __eclipsaComponent(render, 'mixed-signals', () => [])
+ container.imports.set('mixed-signals', Promise.resolve({ default: render }))
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(App, {}, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ const originalLocal = local
+ expect(isCompiledSignal(local)).toBe(true)
+ local.value = 2
+ shared.value = 1
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(local).toBe(originalLocal)
+ expect(container.doc!.body.textContent).toContain('1:2')
+ local.value = 3
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(container.doc!.body.textContent).toContain('1:3')
+ const component = [...container.components.values()].find(
+ (entry) => entry.symbol === 'mixed-signals',
+ )!
+ markResumeHmrBoundaryDirty(container, component.id)
+ await flushDirtyComponents(container)
+ expect(container.doc!.body.textContent).toContain('1:0')
+ })
+ })
+
+ it('automatically flushes signal writes in client-created library components', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const label = createDetachedRuntimeSignal(container, 'library-label', 'before')
+ const Library = __eclipsaComponent(
+ () => jsxDEV('p', { children: label.value }, null, false, {}),
+ '@library:scheduled',
+ () => [],
+ )
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(Library, {}, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ label.value = 'after'
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(container.doc!.body.textContent).toContain('after')
+ expect(container.dirty.size).toBe(0)
+ })
+ })
+
+ it('restores mount callbacks in resumed components without signals', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const start = container.doc!.createComment('ec:c:c0:start')
+ const end = container.doc!.createComment('ec:c:c0:end')
+ container.doc!.body.appendChild(start)
+ container.doc!.body.appendChild(end)
+ const component = createDetachedRuntimeComponent(container, 'mount-only')
+ component.start = start
+ component.end = end
+ component.mountCount = 1
+ component.active = false
+ let mounts = 0
+ container.imports.set(
+ 'mount-only',
+ Promise.resolve({
+ default: () => {
+ onMount(() => {
+ mounts++
+ })
+ return jsxDEV('p', { children: 'mounted' }, null, false, {})
+ },
+ }),
+ )
+ await restoreResumedComponentEffects(container)
+ expect(mounts).toBe(1)
+ await restoreResumedComponentEffects(container)
+ expect(mounts).toBe(1)
+ })
+ })
+
+ it('reactivates client-created library components without a generated symbol URL', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const Library = __eclipsaComponent(
+ (props: { label: string }) => jsxDEV('p', { children: props.label }, null, false, {}),
+ '@library:component',
+ () => [],
+ )
+ const nodes = withRuntimeContainer(container, () =>
+ renderClientInsertable(jsxDEV(Library, { label: 'before' }, null, false, {}), container),
+ )
+ for (const node of nodes) container.doc!.body.appendChild(node)
+ const component = [...container.components.values()].find(
+ (entry) => entry.symbol === '@library:component',
+ )!
+ component.props = { label: 'after' }
+ component.rawProps = null
+ component.active = false
+ container.dirty.add(component.id)
+ await flushDirtyComponents(container)
+ expect(container.doc!.body.textContent).toContain('after')
+ expect(container.symbols.has('@library:component')).toBe(false)
+ })
+ })
+
+ it('navigates fragments without requesting route data', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const doc = container.doc as unknown as FakeDocument & { location: Location }
+ doc.location = new URL('http://local/docs/overview') as unknown as Location
+ const navigations: string[] = []
+ Object.assign(doc, { getElementById: () => null })
+ Object.assign(doc.defaultView, {
+ history: {
+ pushState: (_data: unknown, _title: string, href: string) => navigations.push(href),
+ },
+ })
+ primeLocationState(container, doc.location.href)
+ container.router!.manifest = [
+ {
+ error: null,
+ hasMiddleware: false,
+ layouts: [],
+ loading: null,
+ notFound: null,
+ page: '/docs.js',
+ routePath: '/docs/overview',
+ server: null,
+ segments: [
+ { kind: 'static', value: 'docs' },
+ { kind: 'static', value: 'overview' },
+ ],
+ },
+ ]
+ const originalFetch = globalThis.fetch
+ const requests: string[] = []
+ globalThis.fetch = (async (input) => {
+ requests.push(String(input))
+ throw new Error('Fragment navigation must not fetch')
+ }) as typeof fetch
+ try {
+ const navigate = withRuntimeContainer(container, () => useNavigate())
+ await navigate('/docs/overview#heading')
+ expect(requests).toEqual([])
+ expect(navigations).toEqual(['http://local/docs/overview#heading'])
+ expect(container.router!.currentUrl.value).toBe('http://local/docs/overview#heading')
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+ })
+ })
+})
+
+describe('same-path navigation regressions', () => {
+ it('rerenders the route when only its search parameters change', async () => {
+ await withFakeNodeGlobal(async () => {
+ const container = createContainer()
+ const doc = container.doc as unknown as FakeDocument & { location: Location }
+ doc.location = new URL('http://local/docs?q=one') as unknown as Location
+ Object.assign(doc, { head: doc.createElement('head'), title: '' })
+ Object.assign(doc.defaultView, {
+ history: { pushState: () => {}, replaceState: () => {} },
+ scrollTo: () => {},
+ })
+ container.rootElement = container.doc!.body
+ primeLocationState(container, doc.location.href)
+ const router = container.router!
+ const entry = {
+ error: null,
+ hasMiddleware: false,
+ layouts: [],
+ loading: null,
+ notFound: null,
+ page: '/docs.js',
+ routePath: '/docs',
+ server: null,
+ segments: [{ kind: 'static' as const, value: 'docs' }],
+ }
+ const Page = () => jsxDEV('p', { children: useLocation().search }, null, false, {})
+ const route = {
+ entry,
+ error: undefined,
+ layouts: [],
+ params: {},
+ pathname: '/docs',
+ page: { metadata: null, renderer: Page, symbol: null, url: '/docs.js' },
+ render: () => jsxDEV(Page, {}, null, false, {}),
+ }
+ router.manifest = [entry]
+ router.currentRoute = route
+ router.loadedRoutes.set('/docs::page', route)
+ router.routePrefetches.set(
+ '/docs?q=two',
+ Promise.resolve({
+ finalHref: 'http://local/docs?q=two',
+ finalPathname: '/docs',
+ kind: 'page',
+ loaders: {},
+ ok: true,
+ }),
+ )
+ const navigate = withRuntimeContainer(container, () => useNavigate())
+ await navigate('/docs?q=two')
+ expect(doc.body.textContent).toContain('?q=two')
+ expect(router.currentUrl.value).toBe('http://local/docs?q=two')
+ })
+ })
+})
diff --git a/packages/eclipsa/core/runtime.ts b/packages/eclipsa/core/runtime.ts
index 680cd6c..568b91b 100644
--- a/packages/eclipsa/core/runtime.ts
+++ b/packages/eclipsa/core/runtime.ts
@@ -151,6 +151,7 @@ import {
import { setRuntimeSymbolUrl } from './runtime/kernel.ts'
import {
setRuntimeCleanupHandler as setCompiledRuntimeCleanupHandler,
+ setRuntimeSignalFactory as setCompiledRuntimeSignalFactory,
setRuntimeFixedSignalEffectHandler as setCompiledRuntimeFixedSignalEffectHandler,
setRuntimeEffectWrapper as setCompiledRuntimeEffectWrapper,
setRuntimeMountScheduler as setCompiledRuntimeMountScheduler,
@@ -589,6 +590,7 @@ const createInactiveComponentState = (
externalInstance: undefined,
externalMeta: null,
id,
+ mountCount: 0,
mountCleanupSlots: null,
mayChangeNodeCount: false,
optimizedRoot: false,
@@ -624,6 +626,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
@@ -1333,11 +1336,14 @@ const collectTrackedDependencies = (effect: ReactiveEffect, fn: () => void) => {
const runWithoutDependencyTracking = (fn: () => T): T => {
const previousEffect = currentEffect
+ const previousFrame = currentFrame
currentEffect = null
+ currentFrame = null
try {
return fn()
} finally {
currentEffect = previousEffect
+ currentFrame = previousFrame
}
}
@@ -1443,7 +1449,7 @@ const runWatchCallback = (
collectTrackedDependencies(effect, () => {
trackWatchDependencies(dependencies)
})
- withCleanupSlot(cleanupSlot, fn)
+ runWithoutDependencyTracking(() => withCleanupSlot(cleanupSlot, fn))
}
const createLocalWatchRunner =
@@ -2429,7 +2435,9 @@ const canFlushDirtyComponents = (container: RuntimeContainer) =>
const component = container.components.get(componentId)
return (
!!component &&
- (container.imports.has(component.symbol) || container.symbols.has(component.symbol))
+ (component.clientRenderer ||
+ container.imports.has(component.symbol) ||
+ container.symbols.has(component.symbol))
)
})
@@ -3077,6 +3085,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 +3114,7 @@ const createFrame = (
insertCursor: 0,
keyedRangeCursor: 0,
keyedRangeScopeStack: null,
+ mountCursor: 0,
mountCallbacks: null,
mode,
nextEffectCursor: 0,
@@ -3240,6 +3250,7 @@ const resetComponentForSymbolChange = (
component.prefersEffectOnlyLocalSignalWrites = false
component.projectionSlots = meta.projectionSlots ?? null
component.rawProps = null
+ component.clientRenderer = null
component.externalInstance = undefined
component.externalMeta = null
component.mayChangeNodeCount = false
@@ -3247,6 +3258,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 +3460,10 @@ const pruneComponentWatches = (
component.watchCount = nextCount
}
+const pruneComponentMounts = (component: ComponentState, nextCount: number) => {
+ component.mountCount = nextCount
+}
+
const pruneComponentVisibles = (
container: RuntimeContainer,
component: ComponentState,
@@ -3517,6 +3533,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 +3547,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) {
@@ -4907,11 +4925,13 @@ export const tryPatchNodeSequenceInPlace = (currentNodes: Node[], nextNodes: Nod
}
if (didComponentBoundaryPropsChange(nextUnit.start)) {
const nextRangeNodes = nextUnit.bodyNodes
- if (
- !tryPatchBoundaryContentsInPlace(currentUnit.start, currentUnit.end, nextRangeNodes)
- ) {
- replaceBoundaryContents(currentUnit.start, currentUnit.end, nextRangeNodes)
- }
+ withCompiledReactiveTargetPatchPreservation(false, () => {
+ if (
+ !tryPatchBoundaryContentsInPlace(currentUnit.start, currentUnit.end, nextRangeNodes)
+ ) {
+ replaceBoundaryContents(currentUnit.start, currentUnit.end, nextRangeNodes)
+ }
+ })
}
} else if (currentUnit.rangeKind === 'keyed') {
const nextRangeNodes = nextUnit.bodyNodes
@@ -5886,6 +5906,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')}`
@@ -6058,6 +6079,7 @@ const renderComponentToNodes = (
component.optimizedRoot = meta.optimizedRoot === true
component.props = props
component.rawProps = rawProps ?? null
+ component.clientRenderer = mode === 'client' ? componentFn : null
component.projectionSlots = meta.projectionSlots ?? null
const externalMeta = getExternalComponentMeta(componentFn)
const parentFrame = getCurrentFrame()
@@ -6168,6 +6190,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 +6942,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)
@@ -8111,12 +8135,22 @@ setRuntimeRefAssigner((value, element) => {
setCompiledRuntimeEffectWrapper((fn) => {
const container = getRuntimeContainer()
- return container ? () => pushContainer(container, fn) : fn
+ if (!container) return fn
+ const frame = getCurrentFrame()
+ const effect = createRuntimeReactiveEffect(container, true)
+ effect.fn = () =>
+ runReactiveEffectInContainer(effect, () => collectTrackedDependencies(effect, fn))
+ if (frame && frame.mode === 'client' && frame.component.id !== ROOT_COMPONENT_ID) {
+ addCleanupSlotEffect(ensureFrameEffectCleanupSlot(frame), effect)
+ } else if (currentClientInsertCleanupSlot) {
+ addCleanupSlotEffect(currentClientInsertCleanupSlot, effect)
+ }
+ return () => runEffect(effect)
})
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 +8170,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 +8344,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 +8382,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 +8964,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 +9270,9 @@ const commitRouteNavigation = (
if (options?.writeLocation !== false) {
writeRouterLocation(router, url)
}
- scrollToUrlFragment(doc, url)
+ scrollToUrlTarget(doc, url, {
+ resetScroll: mode !== 'pop',
+ })
}
const renderAndCommitRouteNavigation = (
@@ -9330,6 +9378,7 @@ const navigateContainer = async (
return
}
+ if (container.resumeReadyPromise) await container.resumeReadyPromise
await waitForPendingDirtyFlush()
const mode = options?.mode ?? 'push'
@@ -9352,6 +9401,15 @@ const navigateContainer = async (
return
}
+ if (!force && pathname === router.currentPath.value && url.search === currentRouteUrl.search) {
+ commitBrowserNavigation(doc, url, mode)
+ writeRouterLocation(router, url)
+ scrollToUrlTarget(doc, url, {
+ resetScroll: mode !== 'pop',
+ })
+ return
+ }
+
const prefetchKey = routePrefetchKey(url)
let pendingPrefetch = router.routePrefetches.get(prefetchKey)
if (!pendingPrefetch && routeTarget) {
@@ -9387,15 +9445,6 @@ const navigateContainer = async (
return
}
- if (!force && pathname === router.currentPath.value) {
- if (nextHref !== currentHref) {
- commitBrowserNavigation(doc, url, mode)
- writeRouterLocation(router, url)
- scrollToUrlFragment(doc, url)
- }
- return
- }
-
resetRouteLoaderState(container)
applyPrefetchedLoaders(
container,
@@ -9622,6 +9671,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 =
@@ -9667,13 +9717,21 @@ const activateComponent = async (container: RuntimeContainer, componentId: strin
const oldDescendants = collectDescendantIds(container, componentId)
const scope = materializeScope(container, ensureComponentScopeId(container, component))
await preloadResumableValue(container, scope)
- const module = await loadSymbol(container, activateSymbol)
+ // Library components created on the client already have an executable renderer,
+ // even when their handwritten symbol has no compiler-generated module URL.
+ const clientRenderer =
+ !container.symbols.has(activateSymbol) && !container.imports.has(activateSymbol)
+ ? component.clientRenderer
+ : null
+ const module: RuntimeSymbolModule = clientRenderer
+ ? { default: (_scope, props) => clientRenderer(props) }
+ : await loadSymbol(container, activateSymbol)
const rawProps =
component.rawProps && typeof component.rawProps === 'object' ? component.rawProps : null
if (rawProps) {
component.props = evaluateProps(rawProps)
}
- const externalMeta = getExternalComponentMeta(module.default)
+ const externalMeta = getExternalComponentMeta(clientRenderer ?? module.default)
if (externalMeta && component.props && typeof component.props === 'object') {
component.external = {
kind: externalMeta.kind,
@@ -9741,9 +9799,11 @@ const activateComponent = async (container: RuntimeContainer, componentId: strin
},
component.props,
)
- const speculativeEffectCleanupSlot = component.reuseProjectionSlotDomOnActivate
- ? createCleanupSlot()
- : null
+ const speculativeEffectCleanupSlot =
+ component.reuseProjectionSlotDomOnActivate &&
+ component.preserveCompiledReactiveTargetsOnActivate
+ ? createCleanupSlot()
+ : null
if (!speculativeEffectCleanupSlot) {
resetComponentRenderEffects(component)
}
@@ -9798,6 +9858,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 =
@@ -10094,6 +10155,10 @@ export const markResumeHmrBoundaryDirty = (container: RuntimeContainer, boundary
if (!component) {
return false
}
+ for (const signalId of component.signalIds) {
+ const record = container.signals.get(signalId)
+ if (record?.skipComponentSubscription) record.resetCompiledValueOnRead = true
+ }
resetComponentVisibleStates(container, boundaryId)
component.active = false
component.activateModeOnFlush = 'replace'
@@ -10264,6 +10329,7 @@ export const beginSSRContainer = (
childComponentIds: new Set(),
didMount: false,
id: ROOT_COMPONENT_ID,
+ mountCount: 0,
mountCleanupSlots: [],
parentId: null,
props: {},
@@ -10310,6 +10376,7 @@ export const beginAsyncSSRContainer = async (
childComponentIds: new Set(),
didMount: false,
id: ROOT_COMPONENT_ID,
+ mountCount: 0,
mountCleanupSlots: [],
parentId: null,
props: {},
@@ -10357,6 +10424,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) {
@@ -10421,6 +10489,7 @@ const createResumePayload = (
scope: ensureComponentScopeId(container, component),
signalIds: [...component.signalIds],
symbol: component.symbol,
+ mountCount: component.mountCount,
visibleCount: component.visibleCount,
watchCount: component.watchCount,
} satisfies ResumeComponentPayload,
@@ -10546,6 +10615,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,
@@ -10562,7 +10632,7 @@ export const mergeResumePayload = (container: RuntimeContainer, payload: ResumeP
subscribedSignalIds: null,
suspensePromise: null,
visibleCount: componentPayload.visibleCount ?? 0,
- watchCount: componentPayload.watchCount,
+ watchCount: componentPayload.watchCount ?? 0,
})
}
@@ -10791,7 +10861,7 @@ export const createResumeContainer = (
return container
}
-const canRestoreResumedLocalSignalEffects = (
+const canRestoreResumedComponentEffects = (
container: RuntimeContainer,
component: ComponentState,
) =>
@@ -10799,18 +10869,18 @@ const canRestoreResumedLocalSignalEffects = (
!!component.end &&
!component.active &&
!component.external &&
- component.signalIds.length > 0 &&
+ (component.signalIds.length > 0 || (component.mountCount ?? 0) > 0) &&
(component.symbol === SUSPENSE_COMPONENT_SYMBOL ||
container.imports.has(component.symbol) ||
container.symbols.has(component.symbol))
-export const restoreResumedLocalSignalEffects = async (container: RuntimeContainer) => {
+export const restoreResumedComponentEffects = async (container: RuntimeContainer) => {
let queued = false
const restoredIds: string[] = []
for (const componentId of sortDirtyComponents(
[...container.components.values()]
- .filter((component) => canRestoreResumedLocalSignalEffects(container, component))
+ .filter((component) => canRestoreResumedComponentEffects(container, component))
.map((component) => component.id),
)) {
const component = container.components.get(componentId)
@@ -11866,6 +11936,24 @@ export const useRuntimeAtom = (atom: object, fallback: T): { value: T } => {
return ensureSignalRecord(container, signalId, fallback).handle
}
+setCompiledRuntimeSignalFactory((value) => {
+ const frame = getCurrentFrame()
+ if (!frame || frame.component.id === ROOT_COMPONENT_ID) return null
+ const signal = useRuntimeSignal(value)
+ const meta = getSignalMeta(signal)
+ if (isWritableSignalMeta(meta)) {
+ const record = frame.container.signals.get(meta.id)
+ if (record) {
+ record.skipComponentSubscription = true
+ if (record.resetCompiledValueOnRead) {
+ record.value = value
+ record.resetCompiledValueOnRead = false
+ }
+ }
+ }
+ return signal
+})
+
export const createDetachedRuntimeSignal = (
container: RuntimeContainer,
id: string,
@@ -12473,10 +12561,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)
}
diff --git a/packages/eclipsa/core/runtime/reactive.ts b/packages/eclipsa/core/runtime/reactive.ts
index bc40043..8f24887 100644
--- a/packages/eclipsa/core/runtime/reactive.ts
+++ b/packages/eclipsa/core/runtime/reactive.ts
@@ -8,6 +8,7 @@ type FixedSignalEffectHandler = (
) => boolean
const signalRecords = new WeakMap