Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions javascript/packages/client/src/slots/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import { HERB_ATTRIBUTES } from "../grammar/attributes"

import type { StateManifest } from "../state/types"

export interface BindingEntry {
identifier: string
partial?: string
states: Record<string, string>
}

export interface TemplateManifest {
file: string
identifier: string
version: string
names: Record<string, number>
parts: Record<string, string[]>
bindings?: Record<string, BindingEntry>
states: StateManifest | null
}

Expand Down Expand Up @@ -97,6 +104,10 @@ export class Manifests {
return this.byFile.get(file)?.parts[String(index)] ?? null
}

bindingsOf(file: string, version: string, index: number): BindingEntry | null {
return this.get(file, version)?.bindings?.[String(index)] ?? null
}

statesOf(file: string, version: string): StateManifest | null {
return this.get(file, version)?.states ?? null
}
Expand Down
12 changes: 12 additions & 0 deletions javascript/packages/client/src/slots/region-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,10 +401,19 @@ export class RegionIndex {
const range: RegionRange = { start: comment, end: null }
const existing = this.held.find((candidate) => candidate.file === marker.file && candidate.occurrence === marker.occurrence && candidate.version === marker.version)

const parent = last(state.openRegions)?.region ?? null
const enclosing = parent ? this.slotOpenIn(state, parent) : null

if (existing && existing.ranges.some((candidate) => candidate.start.isConnected)) {
existing.ranges.push(range)
state.openRegions.push({ region: existing, range })

if (existing.parent === null && parent) {
existing.parent = parent
existing.slot = enclosing?.type === "child" ? enclosing : null
existing.item = this.itemAt(state, parent)
}

return
}

Expand All @@ -418,6 +427,9 @@ export class RegionIndex {
occurrence: marker.occurrence,
ranges: [range],
slots: new Map(),
parent,
slot: enclosing?.type === "child" ? enclosing : null,
item: parent ? this.itemAt(state, parent) : null,
}

state.openRegions.push({ region, range })
Expand Down
10 changes: 9 additions & 1 deletion javascript/packages/client/src/slots/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { blankSeeds, blankSlots, interpolateParts, valuesIn, withoutMarkers, fil
import { anchoredSlots, currentHTML, currentText, elementOf, htmlOf, innerRange, rangeOf as rangeOfAnchor, slotOpeners } from "../markup/anchors"

import type { StateManifest } from "../state/types"
import type { TemplateManifest } from "./manifests"
import type { BindingEntry, TemplateManifest } from "./manifests"
import type { ElementObserverDelegate } from "../shared/element-observer"
import type { CollectionsDelegate } from "./collections"
import type { RegionIndexDelegate } from "./region-index"
Expand Down Expand Up @@ -105,6 +105,14 @@ export class Slots implements ElementObserverDelegate, JournalDelegate, Collecti
return this.manifests.statesOf(file, version)
}

bindingsFor(region: Region): BindingEntry | null {
if (!region.parent || !region.slot) {
return null
}

return this.manifests.bindingsOf(region.parent.file, region.parent.version, region.slot.index)
}

holdStatics(identity: StaticsIdentity, statics: Record<string, string>): number {
let parked = 0

Expand Down
211 changes: 198 additions & 13 deletions javascript/packages/client/src/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@ import type { Conditional } from "./types"
import type { RefreshReport } from "./refresh"
import type { StateKind, StateValue } from "./values"
import type { Built, Item, Payload, Region, Slot } from "../types"
import type { CountOptions, DeclaredState, DependencyMap, PlacedSlot, ResolvedStateOptions, ScopeStore, ScopedSetOptions, SerializedState, StateChange, StateChangeDetail, StateListener, StateManifest, StateOptions, StateReport, StateScope, StateSlot, StateSnapshot, StateValues } from "./types"
import type { CountOptions, DeclaredState, DependencyMap, PlacedSlot, ResolvedState, ResolvedStateOptions, ScopeStore, ScopedSetOptions, SerializedState, StateChange, StateChangeDetail, StateListener, StateManifest, StateOptions, StateReport, StateScope, StateSlot, StateSnapshot, StateValues } from "./types"
import type { BindingEntry } from "../slots/manifests"

import type { SlotsDelegate } from "../types"
import type { SeedsDelegate } from "./seeds"
import type { CountsDelegate } from "./counts"
import type { BoundInputsDelegate } from "./bound-inputs"
import type { ElementObserverDelegate } from "../shared/element-observer"

const ALIAS_DEPTH = 8

export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDelegate, BoundInputsDelegate, CountsDelegate {
private readonly slots: Slots
private readonly server: ServerState
Expand Down Expand Up @@ -350,8 +353,10 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
}

const placed = new Map(this.slots.placements(target).map((placement) => [placement.region, placement]))
const enclosing = this.slots.regions().filter((region) => placed.has(region))
const ordered = enclosing.some((region) => this.slots.bindingsFor(region) !== null) ? [...enclosing].reverse() : enclosing

for (const region of this.slots.regions()) {
for (const region of ordered) {
const placement = placed.get(region)

if (!placement) {
Expand Down Expand Up @@ -436,11 +441,41 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
return false
}

const forwarded = new Map<StateScope, StateValues>()
const writes: StateValues = {}

for (const name of names) {
const alias = this.resolveState(this.scopeFor(resolved, name) ?? resolved, name)

if (alias.scope.region === resolved.region) {
writes[name] = values[name]

continue
}

const bucket = forwarded.get(alias.scope) ?? {}

bucket[alias.name] = values[name]
forwarded.set(alias.scope, bucket)
}

let outcome = true

for (const [scope, bucket] of forwarded) {
outcome = this.setState(bucket, { scope }) && outcome
}

const own = Object.keys(writes)

if (own.length === 0) {
return outcome
}

const previous = new Map<string, StateValue>()
const scopes = new Map<string, StateScope>()
const groups = new Map<StateScope, string[]>()

for (const name of names) {
for (const name of own) {
const target = this.scopeFor(resolved, name) ?? resolved
const declaration = this.declaration(manifest, target, name)

Expand Down Expand Up @@ -489,16 +524,22 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
const counted = this.counts.declarationsIn(manifest).map((declaration) => ({ name: declaration.name, previous: this.valueAt(declaration.name, regionScope) }))
const countDependents = this.derivedDependents(manifest, regionScope, counted.map((entry) => entry.name)).map((name) => ({ name, previous: this.valueAt(name, regionScope) }))

for (const [name, value] of Object.entries(values)) {
const snapshots = this.snapshotBound([...groups.keys(), regionScope])

for (const [name, value] of Object.entries(writes)) {
const target = scopes.get(name) ?? resolved

this.store(target, name, value)
this.writeValueSlots(manifest, target, name, value)
}

const recomputedBy = new Map<StateScope, string[]>()

for (const [scope, grouped] of groups) {
const recomputed: string[] = []

recomputedBy.set(scope, recomputed)

for (const dependent of dependents.get(scope) ?? []) {
const value = this.valueAt(dependent.name, scope)

Expand Down Expand Up @@ -538,7 +579,7 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
this.writeComputed(manifest, regionScope, recounted)
}

for (const [name, value] of Object.entries(values)) {
for (const [name, value] of Object.entries(writes)) {
this.announceState(scopes.get(name) ?? resolved, name, value, previous.get(name) ?? null)
}

Expand All @@ -560,17 +601,159 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
}
}

const changed = names.filter((name) => {
const changed = own.filter((name) => {
const value = this.valueAt(name, scopes.get(name) ?? resolved)

return value !== (previous.get(name) ?? null)
})

for (const [scope, grouped] of groups) {
this.propagateToBound(scope, [...grouped, ...(recomputedBy.get(scope) ?? [])], snapshots)
}

if (recounted.length > 0) {
this.propagateToBound(regionScope, recounted, snapshots)
}

if (changed.length > 0) {
this.requestReadRefetch(manifest, regionScope, changed)
}

return true
return outcome
}

resolveState(at: StateScope, name: string): ResolvedState {
let scope = this.scopeFor(at, name) ?? at
let key = name

for (let depth = 0; depth < ALIAS_DEPTH; depth += 1) {
if (scope.item !== null) {
break
}

const region = scope.region
const parent = region.parent
const target = this.slots.bindingsFor(region)?.states[key]

if (!parent || !target || !this.manifestFor(parent)) {
break
}

const above = scopeOf(parent, region.item)

scope = this.scopeFor(above, target) ?? above
key = target
}

return { scope, name: key }
}

private boundChildren(scope: StateScope): Array<{ region: Region; binding: BindingEntry }> {
const found: Array<{ region: Region; binding: BindingEntry }> = []

for (const region of this.slots.regions()) {
if (region.parent !== scope.region) {
continue
}

if (scope.item !== null && region.item !== scope.item) {
continue
}

const binding = this.slots.bindingsFor(region)

if (binding) {
found.push({ region, binding })
}
}

return found
}

private snapshotBound(scopes: Iterable<StateScope>): Map<Region, Map<string, StateValue>> {
const snapshots = new Map<Region, Map<string, StateValue>>()

const visit = (scope: StateScope): void => {
for (const { region, binding } of this.boundChildren(scope)) {
if (snapshots.has(region)) {
continue
}

const manifest = this.manifestFor(region)

if (!manifest) {
continue
}

const childScope = scopeOf(region)
const names = Object.keys(binding.states)
const held = new Map<string, StateValue>()

for (const name of [...names, ...this.derivedDependents(manifest, childScope, names)]) {
held.set(name, this.valueAt(name, childScope))
}

snapshots.set(region, held)
visit(childScope)
}
}

for (const scope of scopes) {
visit(scope)
}

return snapshots
}

private propagateToBound(scope: StateScope, changed: string[], snapshots: Map<Region, Map<string, StateValue>>): void {
for (const { region, binding } of this.boundChildren(scope)) {
const names = Object.entries(binding.states)
.filter(([, parent]) => changed.includes(parent))
.map(([child]) => child)

if (names.length === 0) {
continue
}

const manifest = this.manifestFor(region)

if (!manifest) {
continue
}

const childScope = scopeOf(region)
const before = snapshots.get(region) ?? new Map<string, StateValue>()
const recomputed: string[] = []

for (const name of names) {
this.writeValueSlots(manifest, childScope, name, this.valueAt(name, childScope))
}

for (const dependent of this.derivedDependents(manifest, childScope, names)) {
const value = this.valueAt(dependent, childScope)

if (value === before.get(dependent)) {
continue
}

recomputed.push(dependent)
this.writeValueSlots(manifest, childScope, dependent, value)
}

const all = [...names, ...recomputed]

this.invalidateStaleReads(manifest, childScope, all)
this.writeConditionals(manifest, childScope, all)
this.writePresence(manifest, childScope, all)
this.writeComputed(manifest, childScope, all)

for (const name of all) {
this.announceState(childScope, name, this.valueAt(name, childScope), before.get(name) ?? null)
}

this.propagateToBound(childScope, all, snapshots)
this.requestReadRefetch(manifest, childScope, all)
}
}

private async fetchBlock(region: Region, index: number): Promise<RefreshReport> {
Expand Down Expand Up @@ -823,8 +1006,8 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
}

valueAt(name: string, at: StateScope): StateValue {
const scope = this.scopeFor(at, name) ?? at
const declaration = this.declarationIn(scope, name)
const { scope, name: key } = this.resolveState(at, name)
const declaration = this.declarationIn(scope, key)

if (declaration?.count) {
return this.counts.countFor(declaration, scope)
Expand All @@ -834,19 +1017,19 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
return this.deriveValue(declaration, scope)
}

const stored = this.scoped.get(scope.region)?.get(scope.item?.key ?? "")?.get(name)
const stored = this.scoped.get(scope.region)?.get(scope.item?.key ?? "")?.get(key)

if (stored !== undefined) {
return stored
}

const seeded = this.seeds.valueFor(name, scope)
const seeded = this.seeds.valueFor(key, scope)

if (seeded !== undefined) {
return seeded
}

return this.defaultOf(name, scope)
return this.defaultOf(key, scope)
}

private deriveValue(declaration: DeclaredState, scope: StateScope): StateValue {
Expand Down Expand Up @@ -911,7 +1094,9 @@ export class State implements ElementObserverDelegate, SlotsDelegate, SeedsDeleg
}

declares(scope: StateScope, name: string): boolean {
return this.declarationIn(this.scopeFor(scope, name) ?? scope, name) !== null
const resolved = this.resolveState(scope, name)

return this.declarationIn(resolved.scope, resolved.name) !== null
}

private declarationIn(scope: StateScope, name: string): DeclaredState | null {
Expand Down
Loading
Loading