fix(context): use null-prototype scope and register objects

Add createScope(); use for bottom scope, spawn default, getAll merge, ctx.push frames, filter loops, include/layout blocks registers, and cycle groups. registers uses Object.create(null) and getRegister uses ??.

For-loop continue register defaults to 0 (not {}): Array.slice coerces plain {} but not null-prototype objects.

Export createScope from the package entry.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-12 22:50:51 +08:00
co-authored by Cursor
parent 02dcd4e970
commit a69c3ea2a6
11 changed files with 46 additions and 28 deletions
+8
View File
@@ -216,4 +216,12 @@ describe('Context', function () {
expect(ctx.getSync(['foo'])).toEqual('zoo')
})
})
describe('scope storage', function () {
it('should use null prototype for bottom scope', function () {
expect(Object.getPrototypeOf(new Context().bottom())).toBeNull()
})
it('should use null prototype for getAll() merge result', function () {
expect(Object.getPrototypeOf(new Context({ a: 1 }).getAll())).toBeNull()
})
})
})
+6 -6
View File
@@ -2,7 +2,7 @@ import { getPerformance } from '../util/performance'
import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
import { Scope } from './scope'
import { Scope, createScope } from './scope'
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util'
type PropertyKey = string | number;
@@ -12,8 +12,8 @@ export class Context {
* insert a Context-level empty scope,
* for tags like `{% capture %}` `{% assign %}` to operate
*/
private scopes: Scope[] = [{}]
private registers = {}
private scopes: Scope[] = [createScope()]
private registers: Record<string, any> = Object.create(null)
/**
* user passed in scope
* `{% increment %}`, `{% decrement %}` changes this scope,
@@ -49,7 +49,7 @@ export class Context {
this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit))
}
public getRegister<T> (key: string, defaultValue: T = undefined as T): T {
return (this.registers[key] = this.registers[key] || defaultValue)
return (this.registers[key] = this.registers[key] ?? defaultValue)
}
public setRegister (key: string, value: any) {
return (this.registers[key] = value)
@@ -62,7 +62,7 @@ export class Context {
}
public getAll () {
return [this.globals, this.environments, ...this.scopes]
.reduce((ctx, val) => __assign(ctx, val), {})
.reduce((ctx, val) => __assign(ctx, val), createScope())
}
/**
* @deprecated use `_get()` or `getSync()` instead
@@ -102,7 +102,7 @@ export class Context {
public bottom () {
return this.scopes[0]
}
public spawn (scope = {}) {
public spawn (scope: object = createScope()) {
return new Context(scope, this.opts, {
sync: this.sync,
globals: this.globals,
+10
View File
@@ -5,3 +5,13 @@ interface ScopeObject extends Record<string | number | symbol, any> {
}
export type Scope = ScopeObject | Drop
/**
* Plain scope bag with a null prototype so lookups like `__proto__` are not the
* Object.prototype accessor unless explicitly assigned as an own property.
*/
export function createScope (props?: Record<PropertyKey, any>): ScopeObject {
return props == null
? Object.create(null)
: Object.assign(Object.create(null), props)
}