Repository: harttle/liquidjs (ts)

Issue:
 Inconsistent {% continue %} Behavior in Snippets Between Shopify Liquid and LiquidJS
The behavior of `{% continue %}` inside snippets seems to differ between Shopify Liquid and LiquidJS. In Shopify Liquid, {% continue %} effectively acts as an "early return" from the snippet, preventing any further code in the snippet from rendering. However, in LiquidJS, `{% continue %}` behaves as it would inside a loop, causing the loop in the parent context to skip to the next iteration.

Steps to Reproduce:
1. Create a Liquid snippet (example-snippet.liquid):
```
{% continue %}
<p>This part of the snippet should be skipped</p>
```

2. Use the snippet in a layout/section with a parent loop in LiquidJS:
```
{% for i in (1..3) %}
  <p>Loop iteration: {{ i }}</p>
  {% render 'example-snippet' %}
  <p>This code will be skipped in LiquidJS, but not Shopify Liquid</p>
{% endfor %}
```

Expected Behaviour (based on Shopify Liquid):

```
<p>Loop iteration: 1</p>
<p>This code will be skipped in LiquidJS, but not Shopify Liquid</p>
<p>Loop iteration: 2</p>
<p>This code will be skipped in LiquidJS, but not Shopify Liquid</p>
<p>Loop iteration: 3</p>
<p>This code will be skipped in LiquidJS, but not Shopify Liquid</p>
 ```
 
 What I actually get with LiquidJS is this:
 
 ```
<p>Loop iteration: 1</p>
<p>Loop iteration: 2</p>
<p>Loop iteration: 3</p>
 ```
 
I know that this may seem like a bit of an edge case, but I've seen devs using `{% continue %}` in Snippets as an early return fairly commonly so should probably be fixed

Pull request:
fix: inconsistent continue behaviour, fixes #779

Patch:
diff --git a/src/context/context.ts b/src/context/context.ts
index 5b0306dcc..d23381b6a 100644
--- a/src/context/context.ts
+++ b/src/context/context.ts
@@ -25,6 +25,8 @@ export class Context {
    */
   public globals: Scope
   public sync: boolean
+  public breakCalled = false
+  public continueCalled = false
   /**
    * The normalized liquid options object
    */
diff --git a/src/render/render.ts b/src/render/render.ts
index bebc8f366..6cfc9a99a 100644
--- a/src/render/render.ts
+++ b/src/render/render.ts
@@ -23,7 +23,7 @@ export class Render {
         const html = yield tpl.render(ctx, emitter)
         // if not, it'll return an `html`, write to the emitter for it
         html && emitter.write(html)
-        if (emitter['break'] || emitter['continue']) break
+        if (ctx.breakCalled || ctx.continueCalled) break
       } catch (e) {
         const err = LiquidError.is(e) ? e : new RenderError(e as Error, tpl)
         if (ctx.opts.catchAllErrors) errors.push(err)
diff --git a/src/tags/break.ts b/src/tags/break.ts
index 0ed79388c..4a68a1839 100644
--- a/src/tags/break.ts
+++ b/src/tags/break.ts
@@ -1,7 +1,7 @@
 import { Context, Emitter, Tag } from '..'
 
 export default class extends Tag {
-  render (ctx: Context, emitter: Emitter) {
-    emitter['break'] = true
+  render (ctx: Context, _emitter: Emitter) {
+    ctx.breakCalled = true
   }
 }
diff --git a/src/tags/continue.ts b/src/tags/continue.ts
index 914b8e3a0..0a886242a 100644
--- a/src/tags/continue.ts
+++ b/src/tags/continue.ts
@@ -1,7 +1,7 @@
 import { Tag, Emitter, Context } from '..'
 
 export default class extends Tag {
-  render (ctx: Context, emitter: Emitter) {
-    emitter['continue'] = true
+  render (ctx: Context, _emitter: Emitter) {
+    ctx.continueCalled = true
   }
 }
diff --git a/src/tags/for.ts b/src/tags/for.ts
index de05a1095..4262ce5dd 100644
--- a/src/tags/for.ts
+++ b/src/tags/for.ts
@@ -68,12 +68,9 @@ export default class extends Tag {
     ctx.push(scope)
     for (const item of collection) {
       scope[this.variable] = item
+      ctx.continueCalled = ctx.breakCalled = false
       yield r.renderTemplates(this.templates, ctx, emitter)
-      if (emitter['break']) {
-        emitter['break'] = false
-        break
-      }
-      emitter['continue'] = false
+      if (ctx.breakCalled) break
       scope.forloop.next()
     }
     ctx.pop()


Test patch:
diff --git a/test/e2e/issues.spec.ts b/test/e2e/issues.spec.ts
index ae0e9cc03..d437379fd 100644
--- a/test/e2e/issues.spec.ts
+++ b/test/e2e/issues.spec.ts
@@ -525,8 +525,10 @@ describe('Issues', function () {
     expect(result).toEqual('\n[2,12] foo')
   })
   it("memoryLimit doesn't work in for tag #776", () => {
-    const engine = new Liquid()
+    const engine = new Liquid({
+      memoryLimit: 1e5
+    })
     const tpl = `{% for i in (1..1000000000) %} {{'a'}} {% endfor %}`
-    expect(() => engine.parseAndRenderSync(tpl)).toThrow(/memory out/)
+    expect(() => engine.parseAndRenderSync(tpl)).toThrow('memory alloc limit exceeded, line:1, col:1')
   })
 })
diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts
index fe16deb37..53a8678f1 100644
--- a/test/integration/tags/for.spec.ts
+++ b/test/integration/tags/for.spec.ts
@@ -1,6 +1,7 @@
 import { Liquid } from '../../../src/liquid'
 import { Drop } from '../../../src/drop/drop'
 import { Scope } from '../../../src/context/scope'
+import { mock, restore } from '../../stub/mockfs'
 
 describe('tags/for', function () {
   let liquid: Liquid, scope: Scope
@@ -139,6 +140,7 @@ describe('tags/for', function () {
   })
 
   describe('continue', function () {
+    afterEach(restore)
     it('should support for with continue', async function () {
       const src = '{% for i in (1..5) %}' +
               '{% if i == 4 %}continue{% continue %}{% endif %}{{i}}' +
@@ -154,6 +156,28 @@ describe('tags/for', function () {
       const html = await liquid.parseAndRender(src, scope)
       return expect(html).toBe('123continue5')
     })
+    it('should skip snippet for rendered continue', async function () {
+      mock({
+        'snippet.liquid': ' before{% continue %}skipped'
+      })
+      const src = '{% for i in (1..2) %}' +
+        '{% render "snippet.liquid" %}' +
+        ' after' +
+        '{% endfor %}'
+      const html = await liquid.parseAndRender(src, scope)
+      return expect(html).toBe(' before after before after')
+    })
+    it('should skip `for` body for included continue', async function () {
+      mock({
+        'snippet.liquid': ' before{% continue %}skipped'
+      })
+      const src = '{% for i in (1..2) %}' +
+        '{% include "snippet.liquid" %}' +
+        ' after' +
+        '{% endfor %}'
+      const html = await liquid.parseAndRender(src, scope)
+      return expect(html).toBe(' before before')
+    })
   })
   describe('break', function () {
     it('should support break', async function () {