[web] make route state ownership explicit and clear Svelte warnings #189

Merged
eleith merged 1 commits from fix/reactive-state-ownership into main 2026-11-03 23:58:37 +00:00
10 changed files with 201 additions and 117 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Keep asset editor drafts scoped to their file, reset tag pagination when loaded results change, and ignore obsolete pagination and health-probe responses. Make preview destinations reactive while preserving each job's live progress.
@@ -0,0 +1,9 @@
<script lang="ts">
import type { LayoutProps } from './$types'
let { params, children }: LayoutProps = $props()
</script>
{#key params.asset}
{@render children()}
{/key}
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { onDestroy, onMount, untrack } from 'svelte'
import { enhance, type SubmitFunction } from '$app/forms'
import { Dialog } from 'bits-ui'
import { fade, fly } from 'svelte/transition'
@@ -26,7 +26,8 @@
let { data }: PageProps = $props()
let editorContainer = $state<HTMLDivElement>()
let view = $state<EditorView | null>(null)
let editorContent = $state(data.content || '')
// Save refreshes preserve the draft; the asset-path key resets it on navigation.
let editorContent = $state(untrack(() => data.content ?? ''))
let isSaving = $state(false)
let saveError = $state<string | null>(null)
let saveErrorReason = $state<
@@ -150,22 +151,19 @@
const sortedLanguages = [...languages].sort((a, b) => a.name.localeCompare(b.name))
// Run auto-detection to set initial selection
const ext = data.filename.split('.').pop()?.toLowerCase()
let initialLang = 'Plain Text'
if (ext) {
const langDesc = languages.find(
(l) =>
l.name.toLowerCase() === ext ||
l.extensions.includes(ext) ||
(l.filename && l.filename.test(data.filename))
function detectLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'Plain Text'
return (
languages.find(
(language) =>
language.name.toLowerCase() === ext ||
language.extensions.includes(ext) ||
(language.filename && language.filename.test(filename))
)?.name ?? 'Plain Text'
)
if (langDesc) {
initialLang = langDesc.name
}
}
let selectedLanguageName = $state(initialLang)
let selectedLanguageName = $state(untrack(() => detectLanguage(data.filename)))
function getAppliedTheme(): 'dark' | 'light' {
const preference = window.localStorage.getItem('theme') || 'system'
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { parse } from 'svelte/compiler'
import { expect, test } from 'vitest'
// Structural ownership guard, not proof of client remount or editor teardown.
test('the entire asset editor is keyed by path, not refreshed loader data', () => {
const source = readFileSync(new URL('./+layout.svelte', import.meta.url), 'utf8')
const nodes = parse(source, { modern: true }).fragment.nodes.filter(
(node) => node.type !== 'Text'
)
expect(nodes).toHaveLength(1)
const boundary = nodes[0]
if (boundary.type !== 'KeyBlock') throw new Error('Asset editor must have an identity key')
expect(boundary.expression).toMatchObject({
type: 'MemberExpression',
object: { type: 'Identifier', name: 'params' },
property: { type: 'Identifier', name: 'asset' }
})
expect(boundary.fragment.nodes.filter((node) => node.type !== 'Text')).toMatchObject([
{ type: 'RenderTag', expression: { callee: { type: 'Identifier', name: 'children' } } }
])
})
@@ -9,23 +9,12 @@
type TestResult = { ok: boolean; reason?: string } | null
let storageTest = $state<TestResult>(data.storage)
let storageTest = $derived<TestResult>(data.storage)
let storageTesting = $state(false)
let workerTest = $state<TestResult>(null)
let workerTesting = $state(true)
data.worker.then((result) => {
workerTest = result
workerTesting = false
})
let oidcTest = $state<TestResult>(null)
let oidcTesting = $state(true)
data.oidcIssuer.then((result) => {
oidcTest = result
oidcTesting = false
})
let archiveTest = $state<TestResult>(null)
let archiveTesting = $state(false)
let cdnTest = $state<TestResult>(null)
@@ -33,15 +22,114 @@
let aiTest = $state<TestResult>(null)
let aiTesting = $state(false)
function runTest(setTesting: (v: boolean) => void, setResult: (v: TestResult) => void) {
// A new load supersedes manual checks, including any pending requests.
let activeLoad: PageData | undefined
$effect(() => {
activeLoad = data
storageTesting = false
archiveTesting = false
cdnTesting = false
aiTesting = false
archiveTest = null
cdnTest = null
aiTest = null
return () => {
activeLoad = undefined
}
})
$effect(() => {
const load = data
let active = true
workerTest = null
workerTesting = true
load.worker.then(
(result) => {
if (!active || data !== load) return
workerTest = result
workerTesting = false
},
(error: unknown) => {
if (!active || data !== load) return
workerTest = probeFailure(error)
workerTesting = false
}
)
return () => {
active = false
}
})
$effect(() => {
const load = data
let active = true
oidcTest = null
oidcTesting = true
load.oidcIssuer.then(
(result) => {
if (!active || data !== load) return
oidcTest = result
oidcTesting = false
},
(error: unknown) => {
if (!active || data !== load) return
oidcTest = probeFailure(error)
oidcTesting = false
}
)
return () => {
active = false
}
})
function probeFailure(error: unknown): TestResult {
return { ok: false, reason: error instanceof Error ? error.message : 'health check failed' }
}
function runTest(setTesting: (v: boolean) => void, setResult: (v: TestResult) => void) {
let latestRequest = 0
return () => {
const load = data
const request = ++latestRequest
setTesting(true)
return async ({ result }: { result: ActionResult }) => {
if (activeLoad !== load || data !== load || request !== latestRequest) return
setTesting(false)
setResult(result.type === 'success' ? ((result.data?.result as TestResult) ?? null) : null)
}
}
}
// Keep each form's request counter stable across result/pending-state updates.
const testStorage = runTest(
(v) => (storageTesting = v),
(v) => (storageTest = v)
)
const testWorker = runTest(
(v) => (workerTesting = v),
(v) => (workerTest = v)
)
const testOidcIssuer = runTest(
(v) => (oidcTesting = v),
(v) => (oidcTest = v)
)
const testArchive = runTest(
(v) => (archiveTesting = v),
(v) => (archiveTest = v)
)
const testCdn = runTest(
(v) => (cdnTesting = v),
(v) => (cdnTest = v)
)
const testAi = runTest(
(v) => (aiTesting = v),
(v) => (aiTest = v)
)
</script>
<svelte:head>
@@ -107,14 +195,7 @@
</div>
{#if data.configSummary.auth.issuer}
<div class="info-card-actions">
<form
method="POST"
action="?/testOidcIssuer"
use:enhance={runTest(
(v) => (oidcTesting = v),
(v) => (oidcTest = v)
)}
>
<form method="POST" action="?/testOidcIssuer" use:enhance={testOidcIssuer}>
{@render testButton(oidcTesting, 'test issuer')}
</form>
</div>
@@ -138,14 +219,7 @@
</dl>
</div>
<div class="info-card-actions">
<form
method="POST"
action="?/testStorage"
use:enhance={runTest(
(v) => (storageTesting = v),
(v) => (storageTest = v)
)}
>
<form method="POST" action="?/testStorage" use:enhance={testStorage}>
{@render testButton(storageTesting)}
</form>
</div>
@@ -175,14 +249,7 @@
</dl>
</div>
<div class="info-card-actions">
<form
method="POST"
action="?/testArchive"
use:enhance={runTest(
(v) => (archiveTesting = v),
(v) => (archiveTest = v)
)}
>
<form method="POST" action="?/testArchive" use:enhance={testArchive}>
{@render testButton(archiveTesting)}
</form>
</div>
@@ -218,14 +285,7 @@
</dl>
</div>
<div class="info-card-actions">
<form
method="POST"
action="?/testCdn"
use:enhance={runTest(
(v) => (cdnTesting = v),
(v) => (cdnTest = v)
)}
>
<form method="POST" action="?/testCdn" use:enhance={testCdn}>
{@render testButton(cdnTesting)}
</form>
</div>
@@ -254,14 +314,7 @@
</dl>
</div>
<div class="info-card-actions">
<form
method="POST"
action="?/testWorker"
use:enhance={runTest(
(v) => (workerTesting = v),
(v) => (workerTest = v)
)}
>
<form method="POST" action="?/testWorker" use:enhance={testWorker}>
{@render testButton(workerTesting)}
</form>
</div>
@@ -290,14 +343,7 @@
</dl>
</div>
<div class="info-card-actions">
<form
method="POST"
action="?/testAi"
use:enhance={runTest(
(v) => (aiTesting = v),
(v) => (aiTest = v)
)}
>
<form method="POST" action="?/testAi" use:enhance={testAi}>
{@render testButton(aiTesting, 'test key')}
</form>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import '#lib/pieces/viewer/ViewerBar.css'
import { onMount, onDestroy } from 'svelte'
import { onMount, onDestroy, untrack } from 'svelte'
import { page } from '$app/state'
import { resolve } from '$app/paths'
import type { LayoutProps } from './$types'
@@ -20,25 +20,21 @@
type PreviewStatus = 'enqueued' | 'running' | 'completed' | 'failed' | 'expired'
// The job key owns this state: loader data seeds it, then the stream updates it.
let status = $state<PreviewStatus>(
data.status === 'waiting' ? 'enqueued' : data.status === 'expired' ? 'expired' : data.status
untrack(() => (data.status === 'waiting' ? 'enqueued' : data.status))
)
let errorMessage = $state(data.errorMessage ?? '')
let errorMessage = $state(untrack(() => data.errorMessage ?? ''))
let streamDisconnected = $state(false)
let phases = $state<ProgressPhase[]>(data.phases ?? [])
let logs = $state(mergePhaseLogs({}, data.logs ?? []))
let phases = $state<ProgressPhase[]>(untrack(() => data.phases ?? []))
let logs = $state(untrack(() => mergePhaseLogs({}, data.logs ?? [])))
let eventSource: EventSource | null = null
const path = encodeArchivePath(data.file)
const jobId = encodeURIComponent(data.job)
const path = $derived(encodeArchivePath(data.file))
const jobId = $derived(encodeURIComponent(data.job))
const previewRoute = '/(auth)/admin/piece/[...path]/preview/[jobId]'
const editorUrl = resolve('/(auth)/admin/piece/[...path]/source', { path })
const logsUrl = resolve(previewRoute, { path, jobId })
const pageUrl = resolve(`${previewRoute}/page`, { path, jobId })
const iconUrl = resolve(`${previewRoute}/icon`, { path, jobId })
const shareUrl = resolve(`${previewRoute}/share`, { path, jobId })
function startWatching() {
if (eventSource) eventSource.close()
@@ -94,18 +90,23 @@
</script>
<header class="viewer-bar">
<a href={editorUrl} class="viewer-bar-back" aria-label="Back to editor" title="Back to editor">
<a
href={resolve('/(auth)/admin/piece/[...path]/source', { path })}
class="viewer-bar-back"
aria-label="Back to editor"
title="Back to editor"
>
<ArrowLeftIcon aria-hidden="true" focusable="false" />
</a>
<nav class="viewer-bar-links" aria-label="Preview views">
<a
href={logsUrl}
href={resolve(previewRoute, { path, jobId })}
class="viewer-bar-link"
class:active={page.route.id === previewRoute}
aria-current={page.route.id === previewRoute ? 'page' : undefined}>overview</a
>
<a
href={status === 'completed' ? pageUrl : '#'}
href={status === 'completed' ? resolve(`${previewRoute}/page`, { path, jobId }) : '#'}
class="viewer-bar-link"
class:active={page.route.id === `${previewRoute}/page`}
aria-current={page.route.id === `${previewRoute}/page` ? 'page' : undefined}
@@ -115,7 +116,7 @@
page
</a>
<a
href={status === 'completed' ? iconUrl : '#'}
href={status === 'completed' ? resolve(`${previewRoute}/icon`, { path, jobId }) : '#'}
class="viewer-bar-link"
class:active={page.route.id === `${previewRoute}/icon`}
aria-current={page.route.id === `${previewRoute}/icon` ? 'page' : undefined}
@@ -125,7 +126,7 @@
icon
</a>
<a
href={status === 'completed' ? shareUrl : '#'}
href={status === 'completed' ? resolve(`${previewRoute}/share`, { path, jobId }) : '#'}
class="viewer-bar-link"
class:active={page.route.id === `${previewRoute}/share`}
aria-current={page.route.id === `${previewRoute}/share` ? 'page' : undefined}
@@ -77,7 +77,7 @@ function anchors(html: string) {
return html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? []
}
function expectAppNavigation(path = file, jobId = 'preview-id', renders = 1) {
function expectAppNavigation(path = file, jobId = 'preview-id') {
const calls = [
['/(auth)/admin/piece/[...path]/source', { path }],
[previewRoute, { path, jobId }],
@@ -85,7 +85,7 @@ function expectAppNavigation(path = file, jobId = 'preview-id', renders = 1) {
[`${previewRoute}/icon`, { path, jobId }],
[`${previewRoute}/share`, { path, jobId }]
]
expect(resolve.mock.calls).toEqual(Array.from({ length: renders }, () => calls).flat())
for (const call of resolve.mock.calls) expect(calls).toContainEqual(call)
}
beforeEach(() => {
@@ -284,7 +284,7 @@ test.each(['', '/page', '/icon', '/share'])(
}
expect(html.includes('Preview child fixture')).toBe(status === 'completed')
}
expectAppNavigation(file, 'preview-id', 5)
expectAppNavigation()
expect(EventSource).not.toHaveBeenCalled()
}
)
@@ -328,6 +328,6 @@ test('obsolete html query does not create a mode, extra tab or different active
page.url = new URL(`${root}/share`, 'https://app.test')
page.route.id = `${previewRoute}/share`
expect(body()).toBe(withQuery)
expectAppNavigation(file, 'preview-id', 2)
expectAppNavigation()
expect(EventSource).not.toHaveBeenCalled()
})
@@ -36,20 +36,19 @@ vi.mock('$app/paths', () => ({ resolve }))
// Only the layout's five app-navigation destinations may use this mocked resolver.
// Resource/helper URLs must remain independent; this is not native Kit resolution evidence.
function expectAppNavigation(jobs = ['job']) {
expect(resolve.mock.calls).toEqual(
jobs.flatMap((job) => {
const route = '/(auth)/admin/piece/[...path]/preview/[jobId]'
const path = 'books/book.md'
const jobId = encodeURIComponent(job)
return [
['/(auth)/admin/piece/[...path]/source', { path }],
[route, { path, jobId }],
[`${route}/page`, { path, jobId }],
[`${route}/icon`, { path, jobId }],
[`${route}/share`, { path, jobId }]
]
})
)
const calls = jobs.flatMap((job) => {
const route = '/(auth)/admin/piece/[...path]/preview/[jobId]'
const path = 'books/book.md'
const jobId = encodeURIComponent(job)
return [
['/(auth)/admin/piece/[...path]/source', { path }],
[route, { path, jobId }],
[`${route}/page`, { path, jobId }],
[`${route}/icon`, { path, jobId }],
[`${route}/share`, { path, jobId }]
]
})
for (const call of resolve.mock.calls) expect(calls).toContainEqual(call)
}
type Data = ComponentProps<typeof Overview>['data']
@@ -45,7 +45,7 @@ vi.mock('$app/paths', () => ({ resolve }))
// Only the layout's five destinations and explicit page tag links may use this mock.
// Resource/helper URLs must remain independent; this is not native Kit resolution evidence.
function expectAppNavigation(jobs = ['job'], tags: string[] = []) {
expect(resolve.mock.calls).toEqual([
const calls = [
...jobs.flatMap((job) => {
const route = '/(auth)/admin/piece/[...path]/preview/[jobId]'
const path = 'books/book.md'
@@ -59,7 +59,8 @@ function expectAppNavigation(jobs = ['job'], tags: string[] = []) {
]
}),
...tags.map((tag) => ['/tags/[tag]', { tag: encodeURIComponent(tag) }])
])
]
for (const call of resolve.mock.calls) expect(calls).toContainEqual(call)
}
// Only renderer wrappers are replaced: the real routes, layout, ShareViewport
// and getPieceHelpers run. SSR cannot prove reactive prop updates, client SSE,
+6 -3
View File
@@ -4,14 +4,15 @@
import { resolve } from '$app/paths'
let { data } = $props()
let pieces = $state(data.pieces)
let pieces = $derived(data.pieces)
let activePieceId = $state<string | null>(null)
let nextPage = $state<number | null>(data.nextPage)
let nextPage = $derived<number | null>(data.nextPage)
async function getNextPage(tagPage: number) {
const currentData = data
const query: Record<string, string> = {}
if (tagPage) query.page = tagPage.toString()
query.tag = data.tag
query.tag = currentData.tag
const params = new URLSearchParams(query)
const res = await fetch(`/api/pieces?${params}`, {
@@ -20,8 +21,10 @@
}
})
if (data !== currentData) return
if (res.ok) {
const json = (await res.json()) as Omit<typeof data, 'type'>
if (data !== currentData) return
pieces = pieces.concat(json.pieces)
nextPage = json.nextPage
} else {