ReviewOS

also looking at this

stacks/analyticshq

fix(dashboard): unshadow the bridged range so site switching works

#54
Merged glennmichael123 wants to merge fix/switch-site-range into main
2 files +45 -3
tests/unit/client-payload-hygiene.test.tsadded+40-0
Changes to tests/unit/client-payload-hygiene.test.ts
@@ -0,0 +1,40 @@
1import { describe, expect, test } from 'bun:test'
2import { readdirSync, readFileSync, statSync } from 'node:fs'
3import { join, relative } from 'node:path'
4
5const VIEWS = join(import.meta.dir, '../../resources/views')
6
7function walk(dir: string): string[] {
8 return readdirSync(dir).flatMap((name) => {
9 const path = join(dir, name)
10 if (statSync(path).isDirectory())
11 return walk(path)
12 return path.endsWith('.stx') ? [path] : []
13 })
14}
15
16// stx's server->client data bridge inlines a server binding into the compiled
17// <script client> block only when that block does not itself declare the
18// name. The check is textual, not scoped: a `const range = ...` inside a
19// nested function counts, the bridge then leaves the whole block without
20// `range`, and a top-level use such as switchSite's throws ReferenceError at
21// click time. Nothing at compile time says so. Every name handed to
22// defineClientPayload is therefore reserved throughout the client block.
23describe('client payload hygiene', () => {
24 test('no <script client> block re-declares a name it receives through defineClientPayload', () => {
25 const offenders: Record<string, string[]> = {}
26 for (const file of walk(VIEWS)) {
27 const source = readFileSync(file, 'utf8')
28 const server = [...source.matchAll(/<script\s+server[^>]*>([\s\S]*?)<\/script>/g)].map(match => match[1]).join('\n')
29 const payload = server.match(/defineClientPayload\(\s*\{([\s\S]*?)\}\s*\)/)
30 if (!payload)
31 continue
32 const names = payload[1].split(',').map(entry => entry.split(':')[0].trim()).filter(Boolean)
33 const client = [...source.matchAll(/<script\s+client[^>]*>([\s\S]*?)<\/script>/g)].map(match => match[1]).join('\n')
34 const shadowed = names.filter(name => new RegExp(`(?:const|let|var|function|class)\\s+${name}\\b`).test(client))
35 if (shadowed.length)
36 offenders[relative(VIEWS, file)] = shadowed
37 }
38 expect(offenders).toEqual({})
39 })
40})