From addd7f310d1bc9d034ae4a1409a7c92b86e8a43c Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Thu, 12 Mar 2026 17:18:40 +0200 Subject: [PATCH] test: harden mixed code control-flow emission --- .../test/unit/transform.test.ts | 269 ++++++++++++++++++ .../react-pug-core/src/language/pugToTsx.ts | 6 +- .../react-pug-core/test/unit/pugToTsx.test.ts | 157 ++++++++++ 3 files changed, 431 insertions(+), 1 deletion(-) diff --git a/packages/babel-plugin-react-pug/test/unit/transform.test.ts b/packages/babel-plugin-react-pug/test/unit/transform.test.ts index d4e4371..1a4be32 100644 --- a/packages/babel-plugin-react-pug/test/unit/transform.test.ts +++ b/packages/babel-plugin-react-pug/test/unit/transform.test.ts @@ -152,6 +152,275 @@ describe('babel-plugin-react-pug transform', () => { expect(out).toContain('value'); }); + it('supports unbuffered code followed by a single if/else-if chain', () => { + const out = transform([ + 'const item = { type: "page", value: 1 };', + 'const view = pug`', + ' React.Fragment', + ' - const { type, value } = item', + " if type === 'page'", + ' span= value', + " else if type === 'status'", + ' span Status', + ' else', + ' span Other', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const item = { + type: "page", + value: 1 + }; + const view = {(() => { + const { + type, + value + } = item; + return type === 'page' ? {value} : type === 'status' ? Status : Other; + })()};" + `); + }); + + it('supports unbuffered code followed by a single each loop', () => { + const out = transform([ + 'const values = ["a", "b"];', + 'const view = pug`', + ' React.Fragment', + ' - const items = values', + ' each item, index in items', + ' span(key=index)= item', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const values = ["a", "b"]; + const view = {(() => { + const items = values; + return (() => { + const __pugEachResult = []; + let __pugEachIndex = 0; + for (const item of items) { + const index = __pugEachIndex; + __pugEachResult.push({item}); + __pugEachIndex++; + } + return __pugEachResult; + })(); + })()};" + `); + }); + + it('supports unbuffered code followed by a single each loop with else', () => { + const out = transform([ + 'const values = [];', + 'const view = pug`', + ' React.Fragment', + ' - const items = values', + ' each item, index in items', + ' span(key=index)= item', + ' else', + ' span Empty', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const values = []; + const view = {(() => { + const items = values; + return (() => { + const __pugEachResult = []; + let __pugEachIndex = 0; + for (const item of items) { + const index = __pugEachIndex; + __pugEachResult.push({item}); + __pugEachIndex++; + } + return __pugEachResult.length ? __pugEachResult : Empty; + })(); + })()};" + `); + }); + + it('supports unbuffered code followed by a single while loop', () => { + const out = transform([ + 'const view = pug`', + ' React.Fragment', + ' - let index = 0', + ' while index < 2', + ' - index++', + ' span= index', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const view = {(() => { + let index = 0; + return (() => { + const __r = []; + while (index < 2) { + __r.push((() => { + index++; + return {index}; + })()); + } + return __r; + })(); + })()};" + `); + }); + + it('supports unbuffered code followed by a single case chain', () => { + const out = transform([ + 'const inputKind = "page";', + 'const view = pug`', + ' React.Fragment', + ' - const kind = inputKind', + ' case kind', + " when 'page'", + ' span Page', + " when 'status'", + ' span Status', + ' default', + ' span Other', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const inputKind = "page"; + const view = {(() => { + const kind = inputKind; + return kind === 'page' ? Page : kind === 'status' ? Status : Other; + })()};" + `); + }); + + it('supports sibling JSX around a conditional chain after unbuffered code', () => { + const out = transform([ + 'const item = { type: "page", value: 1 };', + 'const view = pug`', + ' React.Fragment', + ' - const { type, value } = item', + ' span Before', + " if type === 'page'", + ' span= value', + ' else', + ' span Other', + ' span After', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const item = { + type: "page", + value: 1 + }; + const view = {(() => { + const { + type, + value + } = item; + return <>Before{type === 'page' ? {value} : Other}After; + })()};" + `); + }); + + it('supports sibling JSX around an each loop after unbuffered code', () => { + const out = transform([ + 'const values = ["a", "b"];', + 'const view = pug`', + ' React.Fragment', + ' - const items = values', + ' span Before', + ' each item, index in items', + ' span(key=index)= item', + ' span After', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const values = ["a", "b"]; + const view = {(() => { + const items = values; + return <>Before{(() => { + const __pugEachResult = []; + let __pugEachIndex = 0; + for (const item of items) { + const index = __pugEachIndex; + __pugEachResult.push({item}); + __pugEachIndex++; + } + return __pugEachResult; + })()}After; + })()};" + `); + }); + + it('supports nested each inside a conditional chain after unbuffered code', () => { + const out = transform([ + 'const values = ["a", "b"];', + 'const visible = true;', + 'const view = pug`', + ' React.Fragment', + ' - const items = values', + ' - const show = visible', + ' if show', + ' each item, index in items', + ' span(key=index)= item', + ' else', + ' span Hidden', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const values = ["a", "b"]; + const visible = true; + const view = {(() => { + const items = values; + const show = visible; + return show ? (() => { + const __pugEachResult = []; + let __pugEachIndex = 0; + for (const item of items) { + const index = __pugEachIndex; + __pugEachResult.push({item}); + __pugEachIndex++; + } + return __pugEachResult; + })() : Hidden; + })()};" + `); + }); + + it('supports nested conditionals inside an each loop after unbuffered code', () => { + const out = transform([ + 'const values = [{ visible: true, label: "A" }, { visible: false, label: "B" }];', + 'const view = pug`', + ' React.Fragment', + ' - const items = values', + ' each item, index in items', + ' if item.visible', + ' span(key=index)= item.label', + ' else', + ' span(key=index) Hidden', + '`;', + ].join('\n')); + expect(out).toMatchInlineSnapshot(` + "const values = [{ + visible: true, + label: "A" + }, { + visible: false, + label: "B" + }]; + const view = {(() => { + const items = values; + return (() => { + const __pugEachResult = []; + let __pugEachIndex = 0; + for (const item of items) { + const index = __pugEachIndex; + __pugEachResult.push(item.visible ? {item.label} : Hidden); + __pugEachIndex++; + } + return __pugEachResult; + })(); + })()};" + `); + }); + it('supports nested pug templates inside ${} interpolation', () => { const out = transform(COMPILER_NESTED_INTERPOLATION_SOURCE); expect(out).toMatchInlineSnapshot(` diff --git a/packages/react-pug-core/src/language/pugToTsx.ts b/packages/react-pug-core/src/language/pugToTsx.ts index 6cfbb85..1cc73f4 100644 --- a/packages/react-pug-core/src/language/pugToTsx.ts +++ b/packages/react-pug-core/src/language/pugToTsx.ts @@ -1398,7 +1398,11 @@ function emitBlockWithCodeSupport( if (jsxNodes.length === 0) { emitter.emitSynthetic('null'); } else if (jsxNodes.length === 1) { - emitNode(jsxNodes[0], emitter, pugText); + // This branch is still in a JS-expression position (`return (...)`), not a JSX-children + // position. Expression-producing nodes such as if/each/while/case must therefore be + // emitted via the expression path, otherwise wrappers like `return ({cond ? ...})` + // can become syntactically invalid. + emitNodeAsExpression(jsxNodes[0], emitter, pugText); } else { emitter.emitSynthetic('<>'); for (const node of jsxNodes) { diff --git a/packages/react-pug-core/test/unit/pugToTsx.test.ts b/packages/react-pug-core/test/unit/pugToTsx.test.ts index 3cd4d21..a61dcfd 100644 --- a/packages/react-pug-core/test/unit/pugToTsx.test.ts +++ b/packages/react-pug-core/test/unit/pugToTsx.test.ts @@ -1063,6 +1063,163 @@ describe('code blocks', () => { expect(result.parseError).toBeNull(); }); + it('keeps a single conditional chain valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const { type, value } = item', + " if type === 'page'", + ' span= value', + " else if type === 'status'", + ' span Status', + ' else', + ' span Other', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const { type, value } = item;return (type === 'page' ? {value} : type === 'status' ? Status : Other);})()})"`); + }); + + it('keeps a single each loop valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const items = values', + ' each item, index in items', + ' span(key=index)= item', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const items = values;return ((() => {const __pugEachResult = [];let __pugEachIndex = 0;for (const item of items) {const index = __pugEachIndex;__pugEachResult.push({item});__pugEachIndex++;}return __pugEachResult;})());})()})"`); + }); + + it('keeps a single each loop with else valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const items = values', + ' each item, index in items', + ' span(key=index)= item', + ' else', + ' span Empty', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const items = values;return ((() => {const __pugEachResult = [];let __pugEachIndex = 0;for (const item of items) {const index = __pugEachIndex;__pugEachResult.push({item});__pugEachIndex++;}return __pugEachResult.length ? __pugEachResult : Empty;})());})()})"`); + }); + + it('keeps a single while loop valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - let index = 0', + ' while index < 2', + ' - index++', + ' span= index', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {let index = 0;return ((() => {const __r = [];while (index < 2) {__r.push((() => {index++;return {index};})());}return __r;})());})()})"`); + }); + + it('keeps a single case chain valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const kind = inputKind', + ' case kind', + " when 'page'", + ' span Page', + " when 'status'", + ' span Status', + ' default', + ' span Other', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const kind = inputKind;return (kind === 'page' ? Page : kind === 'status' ? Status : Other);})()})"`); + }); + + it('keeps sibling JSX around a conditional chain valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const { type, value } = item', + ' span Before', + " if type === 'page'", + ' span= value', + ' else', + ' span Other', + ' span After', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const { type, value } = item;return (<>Before{type === 'page' ? {value} : Other}After);})()})"`); + }); + + it('keeps sibling JSX around an each loop valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const items = values', + ' span Before', + ' each item, index in items', + ' span(key=index)= item', + ' span After', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const items = values;return (<>Before{(() => {const __pugEachResult = [];let __pugEachIndex = 0;for (const item of items) {const index = __pugEachIndex;__pugEachResult.push({item});__pugEachIndex++;}return __pugEachResult;})()}After);})()})"`); + }); + + it('keeps nested each inside a conditional chain valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const items = values', + ' - const show = visible', + ' if show', + ' each item, index in items', + ' span(key=index)= item', + ' else', + ' span Hidden', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const items = values;const show = visible;return (show ? (() => {const __pugEachResult = [];let __pugEachIndex = 0;for (const item of items) {const index = __pugEachIndex;__pugEachResult.push({item});__pugEachIndex++;}return __pugEachResult;})() : Hidden);})()})"`); + }); + + it('keeps nested conditional inside each valid when mixed with unbuffered code', () => { + const pug = [ + 'React.Fragment', + ' - const items = values', + ' each item, index in items', + ' if item.visible', + ' span(key=index)= item.label', + ' else', + ' span(key=index) Hidden', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'runtime' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const items = values;return ((() => {const __pugEachResult = [];let __pugEachIndex = 0;for (const item of items) {const index = __pugEachIndex;__pugEachResult.push(item.visible ? {item.label} : Hidden);__pugEachIndex++;}return __pugEachResult;})());})()})"`); + }); + + it('keeps the single-child conditional shape valid in language-service mode too', () => { + const pug = [ + 'React.Fragment', + ' - const { type, value } = item', + " if type === 'page'", + ' span= value', + ' else', + ' span Other', + ].join('\n'); + const result = compilePugToTsx(pug, { mode: 'shadow' }); + expect(result.parseError).toBeNull(); + expect(result.transformError).toBeNull(); + expect(result.tsx).toMatchInlineSnapshot(`"({(() => {const { type, value } = item;return (type === 'page' ? {value} : Other);})()})"`); + }); + it('code block expression is mapped with FULL_FEATURES', () => { const pug = '- const x = 10\nspan= x'; const result = compilePugToTsx(pug);