mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
19745b7b0d
After a save conflict, Keep mine set the version and unblocked writes but never re-queued the local document, and write() had already cleared the queue before the request. So if the user picked Keep mine and then simply closed the editor without typing anything else, saveNow() found nothing pending and did nothing: their afternoon of edits existed only in memory, the indicator said idle, and the server kept the other version. The exact outcome the conflict dialog promises to prevent. Ctrl+Z was intercepted while typing in a text field too. Studio commits text on blur, so mid-edit the typing is not in the undo history yet: the browser's own text undo was suppressed and a previous layout change was undone underneath the open editor instead. Reads as random data loss. Also: delete no longer removes locked elements, the auto-layout no longer freezes an empty GPS path when tracks resolve after the one-shot build, the back-cover tally cannot print "0 places", the map fetch aborts when Studio closes, and a double Enter in the list-name field stops creating two lists. The page-pattern gate had quietly stopped covering anything: the Desktop-shim refactor moved every real container out of the default export the checker reads, and its regex missed useRef<T>(null) anyway. Widened on both counts, and the state it immediately found moved into the hooks.
63 lines
3.1 KiB
JavaScript
63 lines
3.1 KiB
JavaScript
// Guards the "Page = wiring container + data hook" convention (see
|
|
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
|
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
|
//
|
|
// We scan the default-export component body (from `export default function` up
|
|
// to the next top-level `function` declaration or EOF), and the same span of a
|
|
// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking
|
|
// the branch, the default export is a two-line shim and the real container is
|
|
// one of those two. Everything else at top level stays exempt: presentational
|
|
// sub-components and helper hooks live in these files on purpose.
|
|
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
|
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
|
import { readdirSync, readFileSync } from 'node:fs'
|
|
import { join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
|
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
|
// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the
|
|
// same thing, and it slipped through for as long as the pattern only knew `(`.
|
|
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`)
|
|
|
|
// Declarations the widened scan caught that are not this change's to move.
|
|
// Listed by the name they declare rather than by line, so an entry dies with
|
|
// its declaration instead of drifting onto whatever follows it. The rest of
|
|
// each listed file is still checked.
|
|
const KNOWN_ESCAPES = {
|
|
'TripPlannerPage.tsx': ['glMap', 'bookingExpense'],
|
|
}
|
|
|
|
const violations = []
|
|
for (const file of readdirSync(pagesDir)) {
|
|
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
|
const src = readFileSync(join(pagesDir, file), 'utf8')
|
|
const lines = src.split('\n')
|
|
const allowed = KNOWN_ESCAPES[file] ?? []
|
|
const starts = []
|
|
lines.forEach((line, i) => {
|
|
if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i)
|
|
})
|
|
for (const start of starts) {
|
|
// The page body ends at the next top-level declaration (a `function` at
|
|
// column 0) — everything after that is a sub-component or helper.
|
|
let end = lines.length
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
|
}
|
|
for (let i = start; i < end; i++) {
|
|
if (!bannedRe.test(lines[i])) continue
|
|
if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue
|
|
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error('Page-pattern violations — move this state/effect logic into the page\'s use<Page>() hook:\n')
|
|
for (const v of violations) console.error(' ' + v)
|
|
console.error(`\n${violations.length} violation(s). See src/pages/PATTERN.md.`)
|
|
process.exit(1)
|
|
}
|
|
console.log('Page pattern OK — no state/effect logic in page containers.')
|