Skip to content

[Feature Request] Native context API for Marko v6 (setContext/getContext) #3155

@defunkt-dev

Description

@defunkt-dev

Description

Marko v6 has no public context API. Every other major frontend framework provides one — React has createContext/useContext, Svelte has setContext/getContext, Vue has provide/inject, and Marko v5 had @marko-tags/context. Marko v6 currently has nothing equivalent.

The infrastructure already exists internally. The #ClosestBranch/#ParentBranch branch chain was clearly designed for this purpose — it is a tree of rendering branches that perfectly models the parent-to-child context relationship. The only missing pieces are:

  1. Export addStatement and scopeIdentifier from the translator bundle so third-party translator packages can inject context reads/writes into the signal graph at compile time
  2. Runtime setContext/getContext functions that walk the branch chain
  3. Built-in <context-provider>/<context-consumer> tags using both of the above

These three parts are independently useful and independently mergeable.

Why

Wanting to build a Marko v6 adapter for [dnd-kit](https://github.com/clauderic/dnd-kit), a headless drag-and-drop library. The React, Svelte, and Vue adapters all provide an implicit context pattern where <create-draggable>, <create-droppable>, and <create-sortable> components automatically find their nearest <drag-drop-provider> ancestor without the user threading props through every intermediate component:

<drag-drop-provider onDragEnd(e) { items = move(items, e) }>
  <for|item, index| of=items by="id">
    <create-sortable id=item.id index=index>
      <li>${item.text}</li>
    </create-sortable>
  </for>
</drag-drop-provider>

I started to impl this using a custom translator that writes the manager onto scope["#ClosestBranch"] during the render phase and walks the branch chain in consumer effects. The implementation is nearly there, The only blocker is that addStatement and scopeIdentifier are bundled into dist/translator/index.js but not exported making them inaccessible to third-party translators.

addStatement is used 47 times inside the bundle and never exported. The dist/translator/util/ and dist/translator/visitors/ directories only contain .d.ts type stubs & no .js implementations exist at those paths.

Without exported internals, the only alternative is a runtime workaround using DOM traversal and queueMicrotask to deliver context after mount. This works but defers entity registration by one microtask, adds hidden DOM elements, and cannot work during SSR. It appears less performant than the compile-time approach.

This gap also affects every other library author who needs implicit context like form libraries passing validation state to nested inputs, design systems passing theme tokens, router libraries passing route context to nested links. Context is a fundamental pattern for component library authorship and Marko v6 is currently without it.

Possible Implementation & Open Questions

Part 1 — Export translator internals (two lines, non-breaking):

In src/translator/util/signals.ts:

export { addStatement };

In src/translator/visitors/program/index.ts:

export { scopeIdentifier };

These are already part of the compilation contract. Exporting them is additive and non-breaking. They would be documented as lower-level APIs for translator authors, similar to how @babel/traverse exposes its internals for plugin authors.

Part 2 — Runtime context API :

New file src/runtime/context.ts, exported at @marko/runtime-tags/context:

const CLOSEST_BRANCH = "#ClosestBranch";
const PARENT_BRANCH = "#ParentBranch";

export function setContext(scope: object, key: string | symbol, value: unknown): void {
  (scope as any)[CLOSEST_BRANCH][key] = value;
}

export function getContext<T = unknown>(scope: object, key: string | symbol): T | undefined {
  let branch = (scope as any)[CLOSEST_BRANCH];
  while (branch) {
    if (key in branch) return branch[key] as T;
    branch = branch[PARENT_BRANCH];
  }
  return undefined;
}

export function hasContext(scope: object, key: string | symbol): boolean {
  let branch = (scope as any)[CLOSEST_BRANCH];
  while (branch) {
    if (key in branch) return true;
    branch = branch[PARENT_BRANCH];
  }
  return false;
}

setContext must be called from a render statement (injected via addStatement("render", ...)) so the value is written during the $input phase before any consumer effects run. getContext is safe to call from effects since the render phase always completes first. This timing is equivalent to Svelte's synchronous context.

Part 3 — Built-in tags:

New built-in tags <context-provider> and <context-consumer> in the tags/ directory, with translators that use addStatement to wire context into the signal graph at compile time. The end-user API would be:

<context-provider key="theme" value="dark">
  <themed-button/>
</context-provider>

<!-- in themed-button.marko -->
<context-consumer/theme key="theme"/>
${theme}

This mirrors the v5 @marko-tags/context package and gives Marko v6 a batteries-included context API.

Open questions:

  • Should Part 1 exports be considered stable public API, or documented as @internal with a deprecation escape hatch? would prefer stable but understand the concern about committing to internals.
  • Should getContext throw or return undefined when no ancestor provides the key? i'd lean toward undefined with a separate hasContext guard, matching Vue's inject behavior.
  • Should symbol keys be supported? include them in our proposal since they prevent key collisions across libraries?
  • Is there interest in an @marko-tags/context-v6 standalone package as an interim step before the built-in tags land?

Is this something you're interested in working on?

Yes. if there are alternaye means to achieving it pls comment. thanks

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions