import * as types from '@babel/types' import generator from '@babel/generator' import { ComponentStructure, ChunkType, FileType, ChunkDefinition, } from '@teleporthq/teleport-types' import { createNextUrlSearchParamsPlugin } from '../src/url-search-params-plugin' // The jsx-component chunk the plugin mutates in-place: a `const Comp = () => { // return
; }` shell with an empty body where the plugin should prepend // `const router = useRouter()`. const makeJsxComponentChunk = (): ChunkDefinition => { const body = types.blockStatement([ types.returnStatement( types.jsxElement( types.jsxOpeningElement(types.jsxIdentifier('div'), [], true), null, [], true ) ), ]) const arrow = types.arrowFunctionExpression([], body) const declaration = types.variableDeclaration('const', [ types.variableDeclarator(types.identifier('Comp'), arrow), ]) return { type: ChunkType.AST, fileType: FileType.JS, name: 'jsx-component', content: declaration, linkAfter: [], meta: {}, } } const codeOfChunk = (chunk: ChunkDefinition): string => generator(chunk.content as types.Node).code describe('createNextUrlSearchParamsPlugin', () => { const plugin = createNextUrlSearchParamsPlugin() it('is a no-op when neither page-level searchParams nor state-level urlSearchParamBinding are present', async () => { const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'NoOp', node: { type: 'element', content: { elementType: 'container' } }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) expect(structure.dependencies.useRouter).toBeUndefined() expect(codeOfChunk(chunk)).not.toContain('useRouter') }) it('injects useRouter AND a write-back useEffect for state-level urlSearchParamBinding', async () => { // The state's *initial* value is still seeded from // `window.location.search` (see `createStateHookAST`), so the read side // does not depend on Next's router. But user-driven changes to the state // must propagate back to the URL bar — otherwise cross-page deep links // built off the URL break and a reload loses the user's selection. // // This plugin is the canonical place for that wiring: it emits // const router = useRouter() // useEffect(() => { // ...if (state === '' || state == null) delete query. // else query. = String(state)... // router.replace({ pathname, query }, undefined, { shallow: true }) // }, [state]) // The `useRouter` dep + the `useEffect` dep both need to land in // `structure.dependencies` so the import plugin emits them on the page. const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { selectedCategory: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'categoryFilter' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) expect(structure.dependencies.useRouter).toBeDefined() expect(structure.dependencies.useRouter.path).toBe('next/router') expect(structure.dependencies.useEffect).toBeDefined() // The write-backs delegate to `__tqWriteQueryParam`, whose ref is a // `useRef` — without the dependency the page would reference an unimported // hook and fail at module load. expect(structure.dependencies.useRef).toBeDefined() expect(structure.dependencies.useRef.path).toBe('react') const code = codeOfChunk(chunk) expect(code).toContain('const router = useRouter()') expect(code).toContain('router.replace(') expect(code).toContain('categoryFilter') expect(code).toContain('shallow: true') // Write-back deps include the state and `router.isReady` so the effect // re-runs once the SSG-mounted router hydrates and `router.query` is no // longer empty. expect(code).toContain('selectedCategory') expect(code).toContain('router.isReady') // Read-back effect must also exist and key off `router.query.categoryFilter` // so external URL changes (back/forward, programmatic shallow pushes) // flow back into state. expect(code).toContain('router.query.categoryFilter') expect(code).toContain('setSelectedCategory') }) it('threads a non-empty state default into both effects (sort dropdown case)', async () => { // A binding whose default is a non-empty value (the products-list sort // dropdown defaults to `name-asc`) must NOT be clobbered to `''` when the // router hydrates with no `?sortBy` key — otherwise the DataProvider // re-fetches unsorted. The read-back resolves a missing key back to the // default; the write-back treats the default as a clean/param-free URL. const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { sortBy: { type: 'string', defaultValue: 'name-asc', urlSearchParamBinding: { key: 'sortBy' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) // Write-back: the default value routes to the delete branch (clean URL). expect(code).toContain('sortBy === "" || sortBy == null || sortBy === "name-asc"') // Read-back: an absent/empty URL value falls through to the default. expect(code).toContain('|| "name-asc"') expect(code).toContain('setSortBy') }) it('makes the write-back wait for the read-back on a hydrate-after-mount binding', async () => { // The products-list view toggle keeps its default through hydration and // adopts `?view=` after mount. On the first ready render the state still // holds the default, so a write-back running first would delete the key // the read-back was about to adopt — and the pair would chase each other // (`/products?view=list` → `/products` → …). The adopted flag orders them. const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { productsView: { type: 'string', defaultValue: 'grid', urlSearchParamBinding: { key: 'view', hydrateAfterMount: true }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) expect(code).toContain('const productsViewUrlAdopted = useRef(false)') // Write-back: silent until adopted. expect(code).toMatch( /if \(!router\.isReady\) return;?\s*if \(!productsViewUrlAdopted\.current\) return;?\s*__tqWriteQueryParam\("view"/ ) // Read-back: adopts, then lifts the flag. expect(code).toMatch( /setProductsView\(prev => prev === __nextValue \? prev : __nextValue\);?\s*productsViewUrlAdopted\.current = true/ ) expect(structure.dependencies.useRef).toBeTruthy() // Running again neither duplicates the flag nor the effects. await plugin(structure) expect((codeOfChunk(chunk).match(/productsViewUrlAdopted = useRef/g) || []).length).toBe(1) }) it('is idempotent — running twice does not double-emit write-back or read-back', async () => { const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { selectedCategory: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'categoryFilter' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) await plugin(structure) const code = codeOfChunk(chunk) // Write-back marker: exactly one `router.replace(` should land. expect((code.match(/router\.replace\(/g) || []).length).toBe(1) // Read-back marker: exactly one `setSelectedCategory(` should land. expect((code.match(/setSelectedCategory\(/g) || []).length).toBe(1) // And there should be exactly two `useEffect(` calls (write-back + // read-back) — re-running the plugin must not silently double either. expect((code.match(/useEffect\(/g) || []).length).toBe(2) }) it('handles URL keys that are not valid JS identifiers', async () => { // GUI sometimes ships URL keys with hyphens or dots (e.g. `category-filter`, // `q.cat`). The key is now a plain string argument to the shared writer, // which indexes with `next[key]` — so an awkward key needs no bracket // gymnastics at the call site and can never be emitted as a subtraction. const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { selectedCategory: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'category-filter' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) expect(code).toContain('__tqWriteQueryParam("category-filter"') expect(code).toContain('const __urlValue = router.query["category-filter"]') // And critically the bad form must NOT appear anywhere. expect(code).not.toContain('.category-filter') }) it('dedupes state defs that bind to the same URL param key', async () => { // Two states binding the same URL key would race: each write-back would // overwrite the other's value on every render. The collector picks the // first binding and silently drops subsequent ones for the same key. const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { selectedCategory: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'categoryFilter' }, }, legacyCategoryAlias: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'categoryFilter' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) // Exactly one write-back, exactly one read-back — for the FIRST state // that claimed the key (selectedCategory). expect((code.match(/router\.replace\(/g) || []).length).toBe(1) expect((code.match(/setSelectedCategory\(/g) || []).length).toBe(1) expect(code).not.toContain('setLegacyCategoryAlias(') }) it('URL→state read-back uses functional setState to avoid loops with write-back', async () => { const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { selectedCategory: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'categoryFilter' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) // Functional setter: setSelectedCategory(prev => prev === __nextValue ? prev : __nextValue) // Returning `prev` when equal triggers React's bail-out and breaks the // loop with the write-back effect. expect(code).toMatch(/setSelectedCategory\(prev => prev === __nextValue/) // Array→string normalization (router.query[key] can be string | string[]) expect(code).toContain('Array.isArray(__urlValue)') }) it('drops the URL param when the bound state value goes empty (the "All categories" case)', async () => { const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, stateDefinitions: { selectedCategory: { type: 'string', defaultValue: '', urlSearchParamBinding: { key: 'categoryFilter' }, }, }, } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) // The emitted code must REMOVE the key on an empty value, not write // `?categoryFilter=` — that would leave a sticky empty filter in the URL. // `undefined` is how the shared writer is told to drop a key. expect(code).toContain( '__tqWriteQueryParam("categoryFilter", selectedCategory === "" || selectedCategory == null ? undefined : selectedCategory)' ) }) it('activates for page-level searchParams (dynamic references via router.query)', async () => { const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Products', node: { type: 'element', content: { elementType: 'container' } }, searchParams: [{ key: 'category' }], } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) expect(structure.dependencies.useRouter).toBeDefined() expect(structure.dependencies.useRouter.path).toBe('next/router') expect(structure.dependencies.useRouter.meta?.namedImport).toBe(true) expect(codeOfChunk(chunk)).toContain('const router = useRouter()') }) it('is idempotent — does not emit a second `const router = useRouter()` if one already exists (internationalization plugin may have added it first)', async () => { const chunk = makeJsxComponentChunk() // Simulate the internationalization plugin having already injected the // declaration — our plugin must not duplicate it. const arrow = (chunk.content as types.VariableDeclaration).declarations[0] .init as types.ArrowFunctionExpression const body = arrow.body as types.BlockStatement body.body.unshift( types.variableDeclaration('const', [ types.variableDeclarator( types.identifier('router'), types.callExpression(types.identifier('useRouter'), []) ), ]) ) const structure: ComponentStructure = { uidl: { name: 'Page', node: { type: 'element', content: { elementType: 'container' } }, searchParams: [{ key: 'q' }], } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) const code = codeOfChunk(chunk) const occurrences = (code.match(/const router = useRouter\(\)/g) || []).length expect(occurrences).toBe(1) }) it('treats an empty searchParams array as absent (no useRouter)', async () => { const chunk = makeJsxComponentChunk() const structure: ComponentStructure = { uidl: { name: 'Empty', node: { type: 'element', content: { elementType: 'container' } }, searchParams: [], } as never, options: {}, chunks: [chunk], dependencies: {}, } await plugin(structure) expect(structure.dependencies.useRouter).toBeUndefined() expect(codeOfChunk(chunk)).not.toContain('useRouter') }) })