[web] make route state ownership explicit and clear Svelte warnings #189
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user