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; value: unknown }>() +const ownedSignals = new WeakSet() let currentEffect: Effect | null = null let currentCleanups: Cleanup[] | null = null let runtimeCleanupHandler: ((fn: Cleanup) => boolean) | null = null @@ -15,6 +16,11 @@ let runtimeEffectWrapper: ((fn: Effect) => Effect) | null = null let runtimeFixedSignalEffectHandler: FixedSignalEffectHandler | null = null let runtimeMountScheduler: ((fn: () => void) => boolean) | null = null let runtimeVisibleHandler: ((fn: () => void) => boolean) | null = null +let runtimeSignalFactory: ((value: T) => Signal | null) | null = null + +export const setRuntimeSignalFactory = (factory: typeof runtimeSignalFactory) => { + runtimeSignalFactory = factory +} export const setRuntimeCleanupHandler = (handler: ((fn: Cleanup) => boolean) | null) => { runtimeCleanupHandler = handler @@ -37,7 +43,9 @@ export const setRuntimeVisibleHandler = (handler: ((fn: () => void) => boolean) } export const isSignal = (value: unknown): value is Signal => - !!value && (typeof value === 'object' || typeof value === 'function') && signalRecords.has(value) + !!value && + (typeof value === 'object' || typeof value === 'function') && + (signalRecords.has(value) || ownedSignals.has(value)) const createSignal = (initialValue: T): Signal => { const record = { @@ -69,7 +77,14 @@ const createSignal = (initialValue: T): Signal => { return handle } -export const useSignal = createSignal +export const useSignal = (initialValue: T): Signal => { + const ownedSignal = runtimeSignalFactory?.(initialValue) + if (ownedSignal) { + ownedSignals.add(ownedSignal) + return ownedSignal + } + return createSignal(initialValue) +} export const signal = createSignal export const effect = (fn: () => void) => { diff --git a/packages/eclipsa/core/runtime/types.ts b/packages/eclipsa/core/runtime/types.ts index d2ec5f2..46a25fc 100644 --- a/packages/eclipsa/core/runtime/types.ts +++ b/packages/eclipsa/core/runtime/types.ts @@ -1,3 +1,4 @@ +import type { Component } from '../component.ts' import type { JSX } from '../../jsx/types.ts' import type { SerializedValue } from '../hooks.ts' import type { ExternalComponentDescriptor, ExternalComponentMeta } from '../meta.ts' @@ -44,6 +45,7 @@ export interface ResumeComponentPayload { scope: string signalIds: string[] symbol: string + mountCount?: number visibleCount: number watchCount: number } @@ -142,6 +144,7 @@ export interface SignalRecord { } id: string skipComponentSubscription?: boolean + resetCompiledValueOnRead?: boolean subscribers: Set | null value: T } @@ -177,6 +180,7 @@ export interface ComponentState { props: unknown projectionSlots: Record | null rawProps?: Record | null + clientRenderer?: Component | null registered?: boolean renderEffectCleanupSlot: CleanupSlot | null reuseExistingDomOnActivate?: boolean @@ -187,6 +191,7 @@ export interface ComponentState { subscribedSignalIds: Set | null symbol: string suspensePromise?: Promise | null + mountCount: number visibleCount: number watchCount: number } @@ -201,6 +206,7 @@ export interface RenderFrame { insertCursor: number keyedRangeCursor: number keyedRangeScopeStack: string[] | null + mountCursor: number mountCallbacks: Array<() => void> | null nextEffectCursor: number nextRenderEffects: RenderEffect[] | null